Tracking: recording, mapping, extending¶
Notes on the tracking framework added for BijectiveRemesh (arXiv 2605.30744): how it works, how
to use it, how to bring it to another application, and how to check that it is right, for
surfaces and for curves (triwild). File paths are relative to the repository root; the memory
interface is in docs/in-memory-io.md.
What it is for¶
An application that remeshes a surface -- or a tet mesh whose tracked faces are a surface --
can record every change it makes to that surface. From the record (a history, saved as a
tracking file) the tracking component rebuilds a map between the input surface and the
output surface, both ways. Users map their own points, and through them their own attributes:
labels, texture coordinates, any per-vertex or per-face data.
The map is built as BijectiveRemesh builds it: one local map per operation, between the patch the operation replaced and the patch it left, composed in order. Where every local map is a bijection, so is the whole map.
The same holds one dimension down. triwild tracks curves -- the input's segments, inserted as edges of its triangle mesh -- and records every change to them; the map then takes the input curves onto the output's tracked edges, and the input's feature points (curve ends, junctions, input points) onto the output vertices they became. See Curves below.
Quick start¶
Turn tracking on with history_output -- the same option in every application that has it:
isotropic_remeshing, qslim, shortest_edge_collapse, tetwild, simwild (surfaces) and
triwild (curves).
{"application": "tetwild", "input": ["model.obj"], "output": "out", "history_output": "out.wmtkhist"}
On the memory route (name(options, meshes), see docs/in-memory-io.md) no file is written:
any non-empty history_output records the history, and the report returns it as history, the
bytes of the file the file route writes (bytes in Python). history::write_history /
wm.write_history save them as that file. simwild also returns the mesh its history starts from
as the output mesh history_input. tetwild and simwild can track the volume around the surface
too, with volume_history_output: see Volumes.
Map points with the tracking application of the same wmtk_app binary:
{"application": "tracking", "history": "out.wmtkhist", "operation": "map",
"points": "points.txt", "output_points": "mapped.txt", "direction": "to_output"}
points.txt holds one point per line; numbers separated by blanks; # starts a comment.
- On the input: face b0 b1 b2. face is a triangle of the input files, counting from 0 over
all of them in order, as the files hold them. b are barycentric coordinates with respect to
that triangle's corners, in file order.
- On the input of a history that starts at a tet mesh's tracked surface (simwild on a
.msh): v0 v1 v2 b0 b1 b2, as on a tet output, with the v vertices of the tet mesh the
history starts from (<output>_history_input.msh).
- On the output of a surface application (isotropic_remeshing): face b0 b1 b2, with face
a triangle of the output file.
- On the output of a tet application (tetwild, simwild): v0 v1 v2 b0 b1 b2. The v are
three vertices of the output tet mesh, at the corners of a tracked face. The tracked surface
is no element of the tet mesh file, so the vertices name the face. They may come in any order;
the coordinates follow them.
direction is to_output or to_input (the inverse map). A point with no image comes back
with id -1.
On curves (triwild) the file says it holds curves, and the points are:
- on the input: segment b0 b1. segment is a segment of the input curve files, counting from
0 over all of them in order -- a polyline l line gives its consecutive vertex pairs -- and
b are barycentric coordinates with respect to its ends in file order;
- on the output: v0 v1 b0 b1, two vertices of the output mesh at the ends of a tracked edge,
in either order, the coordinates following them.
"operation": "features" writes the input's feature points to output_points, one
input_vertex output_vertex per line: a vertex of the input files (the curve files' vertices in
order, then the point files') and the output vertex it ended at, -1 for none; on the memory
route it returns them in the report instead, as feature_points.
From C++ (components/tracking/wmtk/components/tracking/tracking.hpp, the component's one
public header):
const auto t = tracking::Tracking::load("out.wmtkhist");
std::vector<tracking::InputPoint> pts = {{face, {b0, b1, b2}}};
auto on_output = t.to_output(pts); // OutputPoint: .face, or .vertices for a tet output
auto back = t.to_input(on_output); // InputPoint: .face, or .vertices (input_by_vertices)
auto triangles = t.output_triangles(); // the output's tracked faces, in its own ids
// Curves: the same, one dimension down.
const auto c = tracking::CurveTracking::load("curves.wmtkhist");
std::vector<tracking::InputCurvePoint> on_input = {{segment, {b0, b1}}};
auto on_curves = c.to_output(on_input); // OutputCurvePoint: .vertices, two mesh vertices
auto features = c.feature_points(); // {input_vertex, output_vertex} per feature point
From Python, the same classes on NumPy arrays (python/src/tracking_bindings.cpp),
built from the history's bytes -- a memory call's report["history"], or wm.read_history --
or from its file:
outputs, report = wm.isotropic_remeshing({"input": "in", "history_output": "x"}, {"in": mesh})
t = wm.Tracking(report["history"]) # smoothing="flatten", scaffold="box",
# collapse_patch="both_stars"; or .load(path)
ids, bary = t.to_output(faces, bary) # int64 (n,), float64 (n, 3) -> the output's
faces, bary = t.to_input(ids, bary) # and back
ids, bary, warnings = t.to_output(faces, bary, warnings=True) # what this call found
c = wm.CurveTracking(triwild_report["history"]) # or .load(path)
pairs, bary = c.to_output(segments, bary) # int64 (n,), float64 (n, 2) -> (n, 2) vertex pairs
c.feature_points() # int64 (p, 2): input vertex, output vertex
- Ids. A face (segment) per point,
(n,), or a mesh's vertices,(n, 3)--(n, 2)on curves -- on a side named by vertices (output_by_vertices,input_by_vertices): tetwild's and simwild's output, simwild's input from a tet mesh, triwild's output.-1is no face, both ways; a point with no image comes back with-1and NaN coordinates. Shapes and dtypes are checked:TypeErrorfor a dtype that is not an integer (ids) or a float (bary),ValueErrorfor a shape that is not the side's. - Warnings.
warnings=Truereturns a third element, the dictionary of what that call found (the keys ofinfo'sto_input_warnings);build_warningsis the map's. - Properties.
input_faces,input_triangles,output_triangles(surfaces),input_segments,output_segments(curves): owning int64 arrays, in the ids points use. - Threads. Building the map releases the GIL and holds the lock component calls hold, as it
logs through their logger; mapping releases the GIL and writes nothing, so several threads
may map through one object. A damaged history raises
RuntimeError; a curve history given toTracking(or the reverse), or a volume history to either, raisesValueError, naming the other class.
Worked examples, one per application, are unit tests:
- components/tracking/wmtk/components/tracking/tests/test_tracking.cpp (isotropic)
- components/tetwild/wmtk/components/tetwild/tests/test_tetwild_tracking.cpp
- components/simwild/wmtk/components/simwild/tests/test_simwild_tracking.cpp
- components/qslim/wmtk/components/qslim/tests/test_qslim_tracking.cpp
- components/shortest_edge_collapse/wmtk/components/shortest_edge_collapse/tests/test_sec_tracking.cpp
- components/triwild/wmtk/components/triwild/tests/test_triwild_tracking.cpp (curves)
Each one runs the application with tracking on, maps random input points to the output and back, remaps a per-vertex attribute onto the output's vertices, and does the same from the command line with a points file.
On the memory route the applications record the same history, from the meshes they are given
as from their files, and return it as bytes, curves as surfaces. tracking(options, meshes)
takes the history as bytes: it runs info on a surface or a curve history and returns its
report, transfer on a surface, and features on curves, whose report holds the feature points
(feature_points, pairs of an input and an output vertex, -1 for none), and volume on a
volume history's bytes (see Volumes); map and map_volume, whose points are text files,
throw there: Tracking, CurveTracking and VolumeTracking map points in memory. The tests
tracking-memory-route (isotropic) and tracking-memory-route-triwild (curves) run both.
Remapping attributes:
- Per face. Map each output face's centroid to_input and take the input face's value.
The transfer operation does this.
- Per vertex. Map each output vertex to_input as a corner of one of its faces
(barycentric (1,0,0) and its permutations) and interpolate there.
- Per corner, with discontinuities (texture coordinates and their seams). The transfer
operation evaluates each corner just inside its face, so that a corner on a seam lands in
its own chart. This needs the output to conform to the seams (see Features below).
The history¶
src/wmtk/history/History.hpp. History<N> records a complex whose faces have N corners:
SurfaceHistory (N = 3, triangles) or CurveHistory (N = 2, segments; the faces of a curve
history are its segments). history/Fwd.hpp names them all: Replay/CurveReplay,
Recorder/CurveRecorder, TrackedSurfaceRecorder/TrackedCurveRecorder are aliases of
BasicReplay<N>, BasicRecorder<N>, TrackedRecorder<N>. A history holds:
- A snapshot of the complex: vertex and face slots, live or not.
- Features: cells one dimension down that the edits keep -- feature edges of a surface,
feature points of curves. See Features below.
- The edits, in commit order.
- metadata: free text. The applications store their JSON parameters there, with route,
file or memory (ComponentIO::history_metadata), which is how tracking finds an
application's input files when it is not given them -- and knows that a history recorded on
the memory route names meshes, not files, and refuses to look for them.
- output_vertex_ids: for applications whose output numbers vertices its own way.
- input_vertex_ids: the same for the first state, for a history that starts at a tet
mesh's tracked surface rather than at input files: the mesh's id of each snapshot vertex
(TrackedSurfaceRecorder fills it). Empty for a history from files.
Edits are described on the complex alone -- vertex and face ids, positions -- never on the mesh class that did them. A TriMesh and the tracked surface of a TetMesh record in the same terms.
Edit kinds¶
| kind | ids | meaning |
|---|---|---|
split_edge |
v = {v0, v1, new}; faces in pairs (f, new_f) |
each face on the edge keeps its v0 half (v1 renamed new in place); new_f is f with v0 renamed new |
split_face |
v = {v0, v1, v2, new}; faces {f, f1, f2} |
surfaces only. f, f1 and f2 are the three faces at the new vertex, each the old face with one corner renamed |
collapse_edge |
v = {removed, kept} |
faces with both go; removed is renamed kept elsewhere; duplicate faces merge into the smallest id; a vertex left without faces is retired |
swap_edge |
v = {v0, v1, a, b}; faces {fa, fb} |
surfaces only. Edge (v0,v1) becomes (a,b): in fa (apex b) v1 becomes a, in fb (apex a) v0 becomes b |
move_vertices |
none | only positions change |
consolidate |
none | live vertices and faces renumbered by rank |
replace_faces |
v = corners of added faces; faces = removed, then added |
a change no other kind describes; no correspondence; the map cannot follow it |
relabel |
v = new id per vertex slot; faces = new id per face slot, or empty for rank order |
vertices renamed in place (several may become one); faces with a repeated corner go; several faces given one id become one, with the first's corners. The weld, a coordinate change, a reordering. A point keeps its barycentric coordinates |
embed |
v = corners of the new complex; faces = per new face, its sources (count, then ids), then the features |
the complex is replaced by one lying on it (an insertion, an arrangement). A point keeps its place in space |
On curves the rules read the same with segments for faces: a split's face on the edge is the segment itself, a collapse removes the segment it collapses and merges duplicates.
Every edit carries moves: the position, after the edit, of every live vertex it wrote.
Positions that did not change are dropped.
Replay (src/wmtk/history/Replay.hpp) applies edits with exactly these semantics. After the
k-th edit it holds the recording mesh's surface after its k-th committed operation: the same
ids, corner order and positions. Everything downstream relies on that.
Composition (History.hpp):
- history::append(h, next). next starts exactly where h ends. It checks live
vertices and faces, then appends next's edits.
- history::append_embedded(h, next, sources). next starts on a complex lying on h's
last state. It appends an embed edit, then next's edits.
File format¶
Binary, varints (History.cpp). Version 7 is the first released one; the earlier ones were
development formats and no longer load. A byte after the version gives N, the corners of a
face: 3 for a surface, 2 for curves. Faces carry N ids and features N - 1.
history::face_size(path) (or face_size(bytes)) reads it, and loading a history as the other
kind throws. When you add to the format, bump kVersion, keep loading version 7 and up, and
note in the comment at the top of the file what each version added.
In memory a history is the bytes of its file: to_bytes() and History<N>::from_bytes, which
the memory route of a component returns and takes. history::read_history(path) and
history::write_history(path, bytes) move those bytes to and from a file unchanged, as
io::read_mesh and io::write_mesh do for a mesh; they check only the header, of either kind.
save and load are the two together.
Loading checks what it reads before using it, so a damaged file throws "is corrupt" instead of
crashing:
- every count is bounded by the bytes left (each item takes at least one);
- the snapshot's faces name live vertices, and its feature edges existing ones;
- each edit's ids name live vertices and faces of the replayed state (ids_valid), and the
ids it creates stay within what the rest of the file could still announce;
- an embed has a move for every vertex it brings, corners and feature edge ends alike.
A new edit kind needs its case in ids_valid. Tests: history-load-refuses-damaged-files,
history-load-refuses-what-a-map-cannot-follow, surface-map-damaged-history.
Features¶
A surface's features are its feature edges, curves the map must take onto themselves: label boundaries, UV seams,
tetwild feature curves. They are listed only in the snapshot (or in an embed). The edits
carry them by fixed rules, which the recording mesh must follow:
- Split: a split feature edge leaves two feature halves.
- Collapse: the removed vertex's feature edges go to the survivor; the collapsed edge dies.
- Swap: never takes a feature edge.
The map flattens each side of a feature curve separately (sides_of in SurfaceMap.cpp). A
non-manifold edge -- three or more faces -- separates sides without being declared.
On curves the features are points: the ends and junctions of the input's curves and its input
points (input_points files, and isolated vertices of the curve files), and, from the
insertion on, the points the mesh anchors (TriOptimizerMesh's feature point ids). Their rules:
- Split: makes none.
- Collapse: a removed feature point goes to the survivor.
- A feature point keeps its vertex alive with no segment: an input point may lie off every
curve. A filter that removes its mesh vertex leaves it in the history with no output id.
The map ends its arc-length chains at them (and at every vertex of valence other than two), so they map onto themselves.
Recording a TriMesh (surface only)¶
src/wmtk/TriMesh.{h,cpp}. start_history(position) snapshots the mesh and returns a
history::Recorder. From then on every operation commits an edit at its commit point:
- split_edge, split_face, collapse_edge, swap_edge, smooth_vertex, and
consolidate_mesh.
- The commit point is just before release_protect_attributes(), while the operation still
holds its locks. Concurrent commits therefore append in a valid serial order (the recorder
has a mutex).
- The vertices written come from the attribute journal (append_recorded); their positions
come from position(v). It is called inside operations, so it must read through const
access.
Rules for an application on a TriMesh:
- Change the surface only through these operations. Anything else that changes
connectivity or positions escapes the history.
- Feature curves: follow the rules above, and put them in the snapshot's features.
isotropic_remeshing does this from feature_fields.
- Number the output file by the history's last state. Consolidate, then write faces in id
order with their stored corner order. Then output face ids are the history's.
Users: isotropic_remeshing (history_output, feature_fields), qslim and
shortest_edge_collapse (history_output), and the input simplification of tetwild and simwild
(ShortestEdgeCollapse).
Recording a tracked surface on a TetMesh¶
A tet mesh's tracked surface is a subset of its faces, and faces have no stable ids: a face's
attribute slot is 4 * lowest incident tet + local index, which moves whenever its tets
change. So the tracked surface gets ids of its own, in
history::TrackedSurfaceRecorder, TrackedRecorder<3> (src/wmtk/history/TrackedRecorder.{hpp,cpp}).
- Its own ids. The recorder keeps a surface vertex id per mesh vertex on the surface, and
surface face ids through its own
Replay. A mesh renumbering (consolidate_mesh) only renumbers that table, with no edit. - Commits as diffs. At each commit the mesh reports the tracked faces around the operation, before and after, as sorted vertex triples. The recorder takes the difference and finds the edit that explains it:
- Split: every face on the edge goes, and its two halves come.
- Collapse: the faces at
removedare renamedkept, faces with both go, and duplicates merge. Ifkeptwas not on the surface, it takes the removed vertex's surface id and the edit is a move. If both ends were on the surface and no tracked face held the edge, it is a weld, which is counted. - Swap: two faces
(a,b,c)and(a,b,d)become(a,c,d)and(b,c,d). - Nothing changed: a move, if a surface vertex moved.
- Verified before recorded. The predicted result must equal the actual faces after.
Otherwise the change is recorded as it is (
replace_faces) and counted instats().replaced.stats().unknowncounts faces the recorder had lost track of; it should always be 0. - Orientation. A face keeps the corner order the edits give it. A face only a
replace_facesmakes takesorient_tracked_face. resync(faces). Brings the surface in line with the mesh's tracked faces, after a change outside the operations, as onereplace_faces.
TetMesh (src/wmtk/TetMesh.{h,cpp}) calls the recorder:
- Start and stop: start_surface_history(position) and stop_surface_history().
- Capture. Each operation captures the tracked faces around it once its before-hook and
screening have passed (surface_history_before):
- split: faces touching {v1, v2};
- collapse: faces touching {removed, kept};
- swaps: faces with all three corners among the ring vertices.
- Commit. It reports at its commit point (surface_history_commit). Hooked:
split_edge, collapse_edge, the 3-2 swap_edge, the 2-3 swap_face, swap_edge_44,
swap_edge_56, and smooth_vertex.
- Renumbering and resyncing. consolidate_mesh renumbers the recorder.
remove_tets_by_ids (tetwild's filters) resyncs it.
- Virtuals for the application:
- is_tracked_face(tid, j): which faces are tracked;
- orient_tracked_face(f, tets): their corner order, given the face's tets. The
one-argument form looks the tets up; callers that hold only one corner pass them from
faces_at_vertex (see Locking);
- tracked_feature_edges(): the curves on the surface.
- Locking. An operation holds its seed's one-ring ({v1, v2} ∪ N(v1) ∪ N(v2) for an
edge), and only part of the two-ring: see the note above try_set_vertex_mutex_two_ring.
- Reading the star of a held vertex u is race-free: anything that changes a tet at u must
hold u.
- The corners across u's star are two rings out and may not be held. Another thread can be
rewriting their tet lists. So find the star's faces from u's tets alone
(TetMesh::faces_at_vertex: face ids and tets included). Do not use tuple_from_face,
Tuple::fid or tets_of_face, which read all three corners' lists. The fold check first
did, and segfaulted now and then at 8 threads.
- Their positions are safe to read: moving a neighbour of u needs u.
- Dry runs (screening) return before any hook.
TetOptimizerMesh (src/wmtk/TetOptimizerMesh.{h,cpp}), the base of tetwild and simwild,
fills these in:
- is_tracked_face: m_is_surface_fs.
- orient_tracked_face: the input orientation m_orientation on the face (its id from
its lowest tet), ascending vertex order where it is 0.
- tracked_feature_edges: tagged feature edges (m_feature_edge_attribute) that are edges
of tracked faces. tetwild's operations keep them by the history's rules: a split halves one,
a collapse remaps, swaps refuse them.
- record_surface_history(metadata) / finish_surface_history(input).
- finish resyncs, warning if anything drifted.
- It checks that the history's feature edges equal the mesh's, warning if not.
- It fills output_vertex_ids, logs the counts, and returns the history, which the
application saves with ComponentIO::save_history.
- With input, it returns the chain from the input files; see the next section.
- update_attributes_and_history(): update_attributes() followed by a resync, so a
surface re-derived between passes is recorded.
SimWildMesh overrides orient_tracked_face. Each face points out of the tet whose tag set
comes first, so a sheet between two tag sets is oriented the same way everywhere, from the
tags alone.
Recording tracked curves on a TriMesh (triwild)¶
The curve twin of the tet case. A triangle mesh's tracked curves are some of its edges, and
edges have no stable ids either (3 * lowest incident triangle + local index), so they get ids
of their own in history::TrackedCurveRecorder, TrackedRecorder<2>
(src/wmtk/history/TrackedRecorder.{hpp,cpp}) -- the same recorder as the tet one: commits as
diffs of the tracked segments around an operation, the edit that explains them found and
verified, a replace_faces otherwise.
TriMesh (src/wmtk/TriMesh.{h,cpp}) calls it, next to its own commit_history:
- Start and stop: start_curve_history(position), stop_curve_history().
- Capture and commit: curve_history_before(vs) once an operation's before-hook and dry run
have passed, curve_history_commit(kind, v, vs_after) at its commit point. The tracked edges
at a vertex are read from that vertex's own triangles (tracked_edges_around), which the
operation holds. Hooked: split_edge ({v1, v2}, then with the new vertex), collapse_edge
({removed, kept}, then {kept}), swap_edge (its four vertices; a swap never takes a
tracked edge), split_face, smooth_vertex.
- Renumbering and resyncing. consolidate_mesh renumbers the recorder;
remove_tris_by_ids (the filters) resyncs it. remove_tris_by_ids now moves an edge's
attributes to the surviving triangle's slot when it removes the edge's lowest triangle, as
remove_tets_by_ids does in 3D; before, a tracked edge on the filtered region's boundary read
whatever that slot last held (DEBUG_hausdorff and write_vtu read them after the filter).
- A lone feature point. A collapse that removes a feature point no segment holds changes no
segment; the recorder records it all the same, as the collapse or, if the survivor is not on
the curves, as a move.
- Virtuals for the application: is_tracked_edge(e) and tracked_feature_points().
TriOptimizerMesh (src/wmtk/TriOptimizerMesh.{h,cpp}), the base of triwild, fills them in
(m_is_surface_fs, and the vertices with a feature point id) and adds
record_curve_history(metadata, sources) and finish_curve_history(input), as
TetOptimizerMesh does for surfaces: finish resyncs, checks the feature points against the
mesh's, fills output_vertex_ids (the mesh's vertex ids: after the last consolidation, the
vertex rows of the output mesh, which the file route writes with io::write_mesh and the memory
route returns), and returns the chain from the input files, which triwild saves with
ComponentIO::save_history: a file on the file route, report["history"] on the memory route.
From the input files: the chain¶
The tet histories, and isotropic's, start at the input files as read, so that a user's
triangle id is a triangle of their own file. The stages before the mesh go into one history:
- Read and weld. io::read_triangle_mesh(paths, ..., ReadProvenance*) returns the
meshes as read, concatenated, and the vertex and face each vertex and face became; the
cleaning fills it (clean_triangle_mesh), so ComponentIO::triangles gives the same of the
meshes a memory call is given. The snapshot is the raw meshes,
followed by a relabel to the welded mesh. isotropic maps its feature edges onto raw
vertices, and the relabel carries them over.
- Simplify. The simplifier is a TriMesh. Record it with start_history from the welded
mesh, then history::append (tetwild, simwild's EmbedSurface::simplify_surface). Its
consolidation makes the ids the arrangement's triangle ids.
- Insert. utils::embed_triangles_in_tets can return, per facet, the input triangles it
lies on (EmbedTrianglesProvenance::want_face_triangles).
- Exact sources. A facet's sources are the triangles of its coplanar group that it
overlaps with positive area: a separating-axis test in the group's plane, in doubles where
a margin decides, in rationals otherwise.
- Pitfall: coplanar groups are not cut along their internal edges. A UV sphere's quads
are two coplanar triangles, and a facet can lie across both. The centroid test alone
missed this.
- Matching facets to tracked faces. The applications key each facet's sources by its
corners' positions, sorted (TetOptimizerMesh::PositionKey). The tet mesh's m_posf is
the rounding of the same rationals, bit for bit, so record_surface_history finds every
snapshot face's sources there (m_insertion_sources), and warns about any it does not.
- The saved chain. finish_surface_history(&input_history) appends an embed onto
the tet history's first state, then the tet edits.
- simwild's surface route. EmbedSurface records per-file read and weld, the transform,
the split into components and the concatenation as one relabel with an explicit face map,
then the optional simplification, then the embedding. simwild also writes the tet mesh the
history starts from as <output>_history_input.msh.
- triwild's curves (history::curves_from_input, src/wmtk/history/InputHistory.hpp):
- Read and weld. io::read_edge_mesh(..., ReadProvenance*) -- the same struct, its cells
segments, filled by clean_edge_mesh -- gives each file's vertices and segments as written
and where each went, a duplicate segment to the one it duplicates;
utils::read_input_curves concatenates them as it concatenates the network, and triwild
appends the point files' vertices. The snapshot is the files as read, then a relabel with
an explicit segment map (duplicates onto one).
- Simplify. utils::simplify_segments reports its collapses as history edits on the
welded network's rows, and which vertices survive its compaction. The collapse rule keeps
the smaller of two duplicate segments where the simplification keeps the one it did not
move -- the same set, other ids -- so a last relabel onto the compacted rows matches
segments by their vertices.
- Insert. utils::embed_segments already gives each arrangement edge the input segments
it tiles (E_out_sources). The mesh's vertices are the arrangement's rows, so triwild keys
them by vertex pair (TriOptimizerMesh::CurveSources); finish_curve_history appends the
embed, then the mesh's edits.
| application | history starts at | ends at | output point |
|---|---|---|---|
isotropic_remeshing, qslim, shortest_edge_collapse |
the input file as read | the output file | output triangle + barycentrics |
tetwild |
the input files as read | the tracked faces of <output>_final.msh (after any filter) |
three tet vertex ids + barycentrics |
simwild, .msh input |
the tag interfaces of <output>_history_input.msh (input points: three of its vertex ids + barycentrics) |
the tag interfaces of <output>.msh |
three tet vertex ids + barycentrics |
simwild, surface input |
the input files as read | the tag interfaces of <output>.msh |
three tet vertex ids + barycentrics |
triwild |
the input curve files (and point files) as read | the tracked edges of <output>.msh (after any filter) |
two mesh vertex ids + barycentrics |
The map: the tracking component¶
components/tracking/wmtk/components/tracking/:
- SurfaceMap. Built from a history alone. It replays the edits and keeps a step per
edit:
- Splits: exact affine maps of the new faces onto the old.
- Collapses and swaps: a joint flattening of the patch before and after on a shared
outline (Liu et al. 2021).
- Tutte embeddings on the unit circle start it, the outline spaced by 3D length.
- SLIM (slim2d.cpp, Rabinovich et al. 2017) lowers the distortion of both patches at
once. They share the outline's vertices as unknowns, so the boundaries match by
construction. Its line search keeps every triangle positive by exact orient2d.
- A scaffold (Jiang et al. 2017) keeps the outline a simple polygon (Scaffold):
- box, the default, as in the paper. The region between the outline and a box three
times the patch, triangulated anew before every SLIM iteration, each triangle resting
in its current shape. box_scaffold (scaffold2d.cpp) is a constrained Delaunay
triangulation from VolumeRemesher's 2D arrangement (vr2d::build_arrangement: Delaunay,
then the outline's edges forced in by flips). Nothing crosses, so it adds only corners;
the outside is a flood fill from them that never crosses the outline.
- ring: one band of triangles out to a fixed copy of the outline, resting in its first
shape. It pulls the outline back and caps how far it stretches; renders of local maps
made that plain (a long thin patch: distortion 44 with the ring, 4.0 with the box).
- Measured 2026-10-22, box against ring:
- the map takes about 2.4 times as long to build (101168: 21.6 against 9.0 s);
- local maps with energy above 8 (twice an isometry's) drop by more than half;
- mean drift is 25% lower on 101168;
- texture symmetric Dirichlet is 8–22% lower.
- Do not use utils::embed_segments for this. It meshes a background grid and computes
rational coordinates, and was 100 times slower.
- Feature curves split a patch into sides, each flattened on its own.
- Curves that end inside a patch are cut into wedges.
- Where a vertex is removed on the outline, the after face is split virtually, so both
sides keep their area.
- Vertex moves (smoothing), by Smoothing:
- flatten, the default: the vertex's star before and after, flattened jointly per side,
so points stay where they were on the surface. A curve vertex slides along the curve by
arc length.
- barycentric: points keep their face and barycentric coordinates and ride along with
the vertex. The paper does this.
Measured 2026-10-22, flatten against barycentric:
- Barycentric builds the map 1.3–3× faster (101168: 9.0 against 3.4 s; the bunny through
isotropic remeshing: 5.7 against 2.8 s).
- Points drift about 3× further: 0.26 against 0.09 edge lengths on the bunny.
- Transferred textures suffer most. On the remeshed bunny, symmetric Dirichlet 232 against
10.6 (the input's 4.1) and 266 flipped triangles against 83; through tetwild, 68 against
5.2.
- The time saved is at most a few percent of a tetwild run. So flatten stays the default.
- A tangent-plane projection with a flatten fallback kept flatten's quality at half its
cost, but saved only 1–8% of a whole tetwild run, and was not worth its complexity.
relabel: a renumbering.embed: location in space among the sources.replace_faces: faces with nowhere to go.- Warnings.
build_warnings(), and theMapWarningsato_input/to_outputcall fills when given one, say where the map is not a bijection. A call's warnings are its own: mapping never writes to the map, so several threads may map through one at once: not_a_disk: a patch that cannot be flattened, mapped by relabelling -- or a side a collapse removes whole, whose points then have no image;flat_triangle;inverted: a local map with a non-positive triangle;merged_faces: two faces became one;unlocated: a point outside every face of its patch;replaced: faces of areplace_faces.
folded_cells() counts local maps between patches folded over in 3D. They are still
bijections, but stretched.
- Tracking. The user-facing wrapper: input points by file triangles or by
input_vertex_ids triplets; output points by triangles or by output_vertex_ids triplets.
- CurveMap and CurveTracking (CurveMap.{hpp,cpp}, tracking.hpp): the same for
curves, where a joint flattening is exact. Each operation's segments before and after are
split into chains between anchors -- vertices it leaves where they were, junctions, ends,
feature points -- and each chain before is paired with the chain after between the same two
anchors (the removed vertex named as the survivor). The local map takes one onto the other
by normalised arc length: monotone, so a bijection, with constant stretch along the chain.
Splits, collapses and smoothing (Smoothing::flatten; barycentric keeps coordinates) are
all mapped so. Chains with no partner -- the topology changed -- map by relabelling where the
segment still exists. An embed maps each segment before onto the segments after lying on
it, end to end, as a chain by the same arc length -- on collinear pieces, exactly where a
point is in space -- and by projection where those pieces do not form a path; a relabel
by renumbering, flipping coordinates for a duplicate given reversed. Warnings
(CurveMapWarnings): topology (also a collapse along no segment, a weld), merged_segments,
overlapping (input segments that overlap), unlocated, replaced. feature_points()
follows each input feature point's vertex through collapses, relabels and (by position)
embeds to the output vertex it became.
- A junction, an end or a feature point collapsed onto a plain curve vertex moves there:
the survivor ends no chain before the collapse, so the chain the collapsed segment was on
runs on through it. A junction sliding along a branch, and a curve's end eroding onto its
neighbour, map as the shortening they are, without a warning; a collapse of two junctions,
or one along no segment (a weld), is a topology change.
- A chain a collapse contracted has no partner either, and holds the collapsed segment,
whose corners are one now, the survivor: two anchors collapsed into one shrink the segment
between them to the survivor, and a loop of three segments closed merges the other two into
the third. By relabelling the collapsed segment had no image (in triwild's tests at default
topology, with a curve within the envelope of another, up to one input point in a hundred),
so the chain maps by arc length onto what it became: the third segment, or the survivor -- a
chain of no length on a segment there, where every point goes. Forward only: what it became
maps back as it did.
- Segments of no length. The arrangement can set two vertices apart by less than their
rounding; in the history's doubles their segment has no length, until an operation moves
one (11502: two such segments grew to 0.09 when a junction collapsed there). By arc length
such a segment has no room, and all its points map to one end. So measure() counts each
segment for at least a billionth of its chain's mean segment (all alike if the chain has
no length): chains without one keep their arc length exactly, and the others stay one to
one at the cost of a billionth of their length. The chain-based embed does the same for
pieces of no length.
- Mapping points moves, per step, only the points on that step's segments (an index from
segment to points), so it costs little more than the steps that touch them: 0.37 s for
19,804 points through 520k edits.
- transfer_fields / surface_io. Fields of a gmsh input carried onto the output.
- The files are read and written through io::read_mesh / io::write_mesh, so they are
what wm.read_mesh reads and wm.write_mesh writes, and a plain gmsh $ElementData
field is carried too. A field is a triangle attribute of the MeshData: per face, or,
for uv and normal (is_corner_field, the fields io::read_mesh reads per corner),
three values per corner, corner after corner (uv: six columns). A face field keeps its
type and column labels.
- Faces take their centroid's value; corners are evaluated just inside their face.
- Every output face also gets input_face (Int64), the input face it maps to, -1 for none.
- add_surface / write_surface_into_msh write the result onto a copy of a tet mesh
(tet_mesh / output_tet_mesh): its vertices are the tet mesh's by output_vertex_ids
(output_rows), a tracked face already in the triangle block (a simwild face tag) takes
the fields on its row, and any other is a row added after them.
- The app (app.cpp, spec tracking_spec.json; tracking.hpp declares it):
- info: what the map holds, its warnings, the output centroids' round trip -- for curves,
the output segments' midpoints' -- and how far they lie from their preimages (drift):
the mean, the 99th percentile and the largest, in mean output edge (segment) lengths
(lengths, mean_length), in the input's bounding-box diagonal (diagonals, diagonal)
and in the recording's envelope (eps, envelope_eps: eps, or eps_rel of the
diagonal, from the parameters the history saved). large -- warned about -- when the
largest is over 10 output lengths and 1% of the diagonal: a local map slides a point within
the patch it replaced, well under 10 lengths, and a coarse output is not held to its own few
edges. eps alone is no yardstick: on 219409, whose segments are 60 eps long, points slide
112 eps and under 2 segment lengths;
- transfer (surfaces): fields onto the output;
- map: user points;
- features (curves): the input's feature points and the output vertices they became.
The file says whether it holds a surface or curves (history::face_size); the app picks.
Without input, transfer reads the input files from the history's metadata.
On the memory route (tracking(options, meshes)) history is the bytes of a history,
never a file name; info, transfer, features and volume run, map and map_volume
are refused. transfer needs input, a key of meshes, and returns the output surface as
mesh (mesh_from_surface), and with tet_mesh, another key, that mesh with the fields as
tet_mesh (add_surface).
Volumes¶
A tet mesh's tracked surface splits its volume into regions, and the surface history maps only
the surface. With volume_history_output (and history_output, which it follows), tetwild and
simwild in 3D also record the volume around it, and the tracking component maps each region
onto itself -- a point inside stays inside -- between the tet mesh the history starts from and
the one it ends at.
- Where it starts and ends.
- simwild: regions are the tets with the same tags. From
<output>_history_input.mshto<output>.msh; on the memory route the output mesheshistory_inputandmesh. - tetwild: one region, the tracked surface kept on itself. From
<output>_history_input.mshto<output>_history_output.msh, the whole mesh before the filter; on the memory route the output mesheshistory_inputandhistory_output.<output>_final.msh(mesh) is the part the filter keeps: a point of the part dropped has no tet there. - Refused: without
history_output; with tetwild'suse_legacy_code, which rebuilds the mesh; withcoarsen_passandcoarsen_local_smoothing_passesabove 0, whose collapses smooth a ball of vertices the history does not see; simwild in 2D. - The history (
history::VolumeHistory,src/wmtk/history/VolumeHistory.{hpp,cpp}). One edit per operation that changed the tracked surface -- collapse, the four swaps, smoothing -- with the tets around it before and after, their regions (sets of tags), their vertices' positions and surface vertices, and the range of surface edits it made. Every other operation leaves each region where it is in space, and needs no record. - Recording.
TetMesh::start_volume_history(position)(TetOptimizerMesh'srecord_volume_history) starts ahistory::VolumeRecordernext to the surface's. Each operation captures the tets at its vertices before it changes the mesh and at its commit point, and commits them with the surface edits its surface commit returned; one that made none is dropped. Commits may come from several threads. Vertices get ids of their own, which a consolidation does not change, so a position is written again only when it moved, and a collapse's tets after are derived from its tets before. - Finishing.
finish_volume_history(tag_names)returns the bytes of the file, whichComponentIO::save_volume_historywrites on the file route and returns asreport["volume_history"]on the memory route. simwild names the tags as its mesh files label them (io::collect_tags), so a tet's groups give its region. - The file.
"WMTKVOLH", version 2, in the varints and doubles ofBinaryIO.hpp(the layout is inVolumeHistory.cpp), with the key of its surface history: its number of edits and a hash of them (surface_key), which the map checks.read_historyandwrite_historytake it as they take a surface's (is_volume_history);to_bytes()/from_bytes(),save()/load(). A damaged file throws: every edit is checked as read. - The map (
VolumeMap,VolumeMap.{hpp,cpp}, withvolume_complexandvolume_geometry). Per operation and region, pairs of patches, before and after, each embedded star-shaped -- its real geometry when it is, else a fallback (spherical, then a convexbowlorconvexrealization) -- and a point carried along the ray from one centre to the boundary, across by the boundary map (the identity on the faces the operation keeps, the surface map's local map,CollapsePatch::removed_star, on the tracked faces it changes) and back in from the other centre, as far in as it was. Both sides of the surface use the same surface map, so they agree on it. The kinds of pair (op_kinds): - collapses:
small(a side alone),single(both ends' stars),vanishing side(a side whose tets all hold the survivor),interior j->iandmove i(a surface vertex collapsed into one off the surface, mapped the other way round), and through a decomposition where a side has a chord (split,refined-a,unsplit: the chord split at a virtual vertex, the collapse, the virtual vertex collapsed back) -- with one region all round, the whole collapse goes through it; - swaps:
swap side, andswap split(a 3-2 flip on the surface as an exact split and a collapse); - moves:
move, one embedding for both sides; volume only: where no pair keeps the surface -- the boundary of an open sheet, a weld -- one pair over the whole operation keeps the volume a bijection, and the surface is not kept there (surface_not_kept); a point may then change region, and is in its new one from then on.- Lazily, and kept. An operation's map is built when points first go through it, and
kept for later calls up to
VolumeMapOptions::cache_bytes(256 MB; 0 keeps none). The points are found in an operation's tets through a grid over the operations' boxes. On tetwild's 1047980 (about 12,000 operations), 20,000 points take 3.0 s in one call, 4.0 s in 20 calls of 1,000, and 0.55 s in 200 calls of one. - Stats (
VolumeMapStats,stats_report()): maps built and failed, with the reasons, pairs on real geometry and on fallbacks, decompositions,surface_not_kept,inverted_ops(see below), points that changed region or went through a failed map, and the maps kept. - Threads. The const queries may run on several threads at once: the operations built and kept, and the stats, are kept under a mutex, and the maps built outside it.
- No point. A position that is not a number is passed by, as it is.
- Size. The volume history takes several times the room of the surface history: on tetwild's 1017020 33.5 MB, on 101954 3.0 MB.
VolumeTracking(tracking.hpp), the user-facing wrapper:
const auto v = tracking::VolumeTracking::load(
"out.wmtkhist", "out.wmtkvol", "out_history_input.msh", "out_history_output.msh");
std::vector<tracking::TetPoint> pts = {{tet, {b0, b1, b2, b3}}}; // a row of the mesh's tets
auto there = v.to_output(pts); // a tet of the output mesh of the point's own region; kNoId none
auto xyz = v.map_positions(positions, true); // VolumePoint: position, region
// in memory: VolumeTracking(surface, volume, input_mesh, output_mesh), or (surface, volume)
// alone, which maps positions only
A tet point is a row of the mesh's tets, as io::read_mesh (wm.read_mesh) and the memory
route give them, and barycentric coordinates in its corners' order. A point keeps its region:
where it lands on a face between two, it is named by a tet of its own. Copies and moves share
the map and the meshes.
- Python (wm.VolumeTracking):
v = wm.VolumeTracking(report["history"], report["volume_history"],
outputs["history_input"], outputs["history_output"]) # cache_bytes=...
tets, bary = v.to_output(tets, bary) # int64 (n,), float64 (n, 4); -1 and NaN for no tet
tets, bary = v.to_input(tets, bary)
xyz, region = v.map_positions(xyz, direction="to_output", regions=None) # -1: no operation took it
v.has_meshes, v.regions, v.tag_names, v.stats
p = wm.VolumeTracking(report["history"], report["volume_history"]) # positions only
f = wm.VolumeTracking.load(surface_path, volume_path, input_mesh_path, output_mesh_path)
The conventions are wm.Tracking's: TypeError and ValueError for dtypes and shapes,
ValueError for a history of the other kind in either place, RuntimeError for damaged
bytes or a volume history recorded with another surface history; arrays converted under the
GIL, which building and mapping release; one object may be shared by threads.
- The app. volume builds the map from volume_history and checks it on samples points
near the tracked surface of tet_mesh: how far they move, the conformal distortion and
det J of the map there, whether they end in their region of output_tet_mesh, and the round
trip; the report holds it, on both routes (on the memory route the volume history is bytes
and the meshes are keys of the call's). Without tet_mesh it builds every operation's map.
map_volume (file route only) maps a file of points, x y z or tet b0 b1 b2 b3.
- Known limitations.
- Round trips on coarse inputs. The history holds positions in doubles, the exact ones
rounded -- tetwild's and simwild's vertices not rounded yet after the insertion. At those
positions some tets of an operation can be flat or turned the other way round, though they
are not in the exact positions; the tets there overlap, a position is in two, and a point
mapped there and back can come back off by up to the size of the tets around.
inverted_ops counts such operations, and volume warns. On an icosahedron split once
(42 vertices) 61 of about 2,200 operations have one, and 17 of 3,000 points come back off,
the worst by 1.3% of the diagonal; on a unit cube, 176 and 1.7%; on an icosphere of 162
vertices none, and every point comes back to 1e-16. tetwild-volume-coarse-round-trip
pins it. Mapping by exact positions would need them in the history. Rarer: a fallback
embedding's centre next to a boundary apex (apex_centre) can be within 1e-17 of the
patch from a face plane, and that pair's radial map then loses points too; taking another
embedding there made tracking-example-simwild-volume's round trip worse, so it is not
done.
- Operations with no map. Over 200 runs of volume-ops's random operations (25,000 to
31,000 operations in all), 6 to 14 operations have no map, in 1.5 to 3.5% of the runs,
with the envelope at one cube or two, the sheet's boundary held or not: a 4-4 surface swap no split and collapse makes (about
half), a collapse whose surface map flattens both stars where no pair over both stars
embeds and the removed star collapsed is no ball, so no volume-only pair either, and a
decomposition step with a changed boundary face that is not tracked. Their points keep
their place (failed, through_failed). A looser envelope does not change the rate. The
applications' runs in the tests have none.
- det J along intersection curves. simwild's insertion leaves slivers along the curves
where surfaces meet, some of them flat or inverted at the recorded positions. A Jacobian
by finite differences there leaves the sliver at once, and is not positive at a few in a
thousand to 2% of the points that move (4 of 1,185 on two crossing icospheres, all in
slivers); the tests check orientation away from slivers.
- Tests. test_volume_map.cpp (the file and its damage, bytes, pairing, the locator, the
cache, threads, the memory route's volume), test_volume_ops.cpp (each kind of pair on a
jittered grid recorded by hand, deciding on exact shapes so a seed's run is the same on every
platform), the whole runs in test_tetwild_tracking.cpp and test_simwild_tracking.cpp
(Release only), and the Volume tests of tests/python/test_tracking.py.
Extending tracking to another application¶
On a TriMesh¶
- After the mesh is created,
auto rec = mesh.start_history([&](size_t v) { return pos(v); });The position read must be const. - Make the application change the surface only through
split_edge,split_face,collapse_edge,swap_edgeandsmooth_vertex. If it has feature curves, enforce the feature rules and add them torec->history().features. - Start from the input file. Read with
io::read_triangle_mesh(paths, ..., &provenance), then savehistory::from_input(provenance, welded_positions, rec->history())(src/wmtk/history/InputHistory.hpp): the file as read, arelabelto the mesh, then the recorded edits.qslim.cppis the shortest example. - At the end,
consolidate_mesh(), thenstop_history(). Write the output by the history's last state: consolidated, face ids and corner order as stored. - Save the history with
metadataset tocomponent_io.history_metadata(params), throughComponentIO::save_history(h, history_output): a file on the file route, the report'shistoryon the memory route (whose entry point returnstake_result()). - Add
history_outputto the spec with the same name and meaning as in the other applications.
On a TetMesh¶
- Derive from
TetOptimizerMeshif you can: the hooks,record_surface_historyandfinish_surface_historycome with it. Otherwise overrideis_tracked_face,orient_tracked_faceandtracked_feature_edgeson yourTetMesh, and callstart_surface_history(position)yourself. - Orient tracked faces consistently per sheet. Use the input orientation, the tags (simwild), or anything that does not depend on the face's slot.
- Start recording once the tracked surface exists, after insertion and the first
consolidation. Edge splits, collapses, swaps and smoothing report to the history;
split_face,split_tetandinsert_pointhave no hooks and throw while it records. - After anything that changes tracked flags outside the operations -- re-deriving them,
filters, tag edits -- call
resync_surface_history().remove_tets_by_idsdoes it itself. Each resync is areplace_facesand a warning in the map. Avoid them if you can. - Save after the last change to the tet mesh, then write the tet mesh numbered as it is then:
output_vertex_idsare the mesh vertex ids at save time. - For an insertion stage: collect per-facet sources keyed by position
(
m_insertion_sources), and finish with the input history (finish_surface_history(&input)).
What breaks bijectivity on curves¶
- Welds (a collapse joining two curves along no segment), junctions merged, loops closed, and
the input simplification without the link condition: all of them
topologywarnings. Withpreserve_topologythere are none of these left. - Duplicate segments in the input files (
merged_segments), which the weld makes one. - Not a warning, but a distortion: junctions that slide. Without tracking, triwild anchors
only curve ends (
allow_junction_cleanupdefaults to true); a junction is free to move, and does: on 190246 one slid 14 units in a single smoothing step, zipping two curves that run side by side inside the envelope. The map still takes the junction to the junction, so one branch stretches 12 times and the others shrink as much. Points then drift far along the curves (mean 0.97, max 174 segment lengths), and a point that rides the junction loses precision through the steps that stretch it (round trip 6.76 there). With junctions anchored they stay within eps: round trip 2e-13, drift mean 0.11, max 4.0 segment lengths. Sohistory_outputanchors them (allow_junction_cleanupdefaults to false then, unless the config gives it; withoutpreserve_feature_points, which anchors nothing, it is left alone). The freeze the option was introduced for in August, a self-intersecting input stuck at MAX_ENERGY by its anchored crossings, no longer happens on its example: 242427 converges either way at default topology, anchored keeping about 2.5 times the vertices (stop_energy 100: max energy 99.5 and 3443 vertices free, 99.2 and 8483 anchored, 17.5 s and 22.3 s with recording; stop_energy 10: 5476 and 14257 vertices). Withpreserve_topologyit is still at 1e50 either way when stopped after 10 minutes (2.3M edges). - Not a topology warning, but a drift warning: hairpins removed. A curve that folds back on
itself narrower than the envelope can lose the whole fold to one collapse: on 226193 a
776-unit hairpin went to 1.08. The envelope is one-sided -- the output must stay near the
input, not the input near the output -- so the input simplification and the collapses may
cut long strokes that double back. The map squeezes the fold onto what is left by arc
length, so a point near its tip drifts as far as the fold was long (1,648 units there). On
segment soups the folds are many: challenging_triwild_189017 (eps_rel 1e-3, 20 iterations,
4 threads), with
preserve_topologyand junctions anchored, maps with a round trip of 2.8e-6 and no topology warning, yet its midpoints drift a mean of 1.45 segment lengths, a 99th percentile of 32.9 (89 eps) and at most 144 (388 eps, 39% of the diagonal); already after the embed, the input simplification having cut the folds, it is of that order (a 99th percentile of 82 units, at most 220). At default topology the largest is 29 segment lengths (32% of the diagonal).infowarns on both (drift.large). A map of such a collapse by projection, each point of the fold onto the nearest point of what is left, would keep the drift within eps but is not a monotone map of the chain; not done.
Measured 2026-10-22 on 24 networks of the 2D dataset (22 KB to 2.6 MB, 4 threads), info
on each history:
| triwild settings | junctions | topology warnings | worst round trip | below 1e-8 | drift, median of means | edits | map build |
|---|---|---|---|---|---|---|---|
| default | anchored (the default with history_output) |
on 22 of 24 (welds) | 2.4e-9 | 24 of 24 | 0.043 segment lengths | 0.5k–51k | at most 0.20 s |
| default | free (allow_junction_cleanup: true) |
on 22 of 24 (welds) | 2.8e-10 | 24 of 24 | 0.044 | 0.5k–49k | at most 0.16 s |
preserve_topology |
anchored (the default with history_output) |
0 | 3.3e-6 | 18 of 24 | 0.045 | up to 2.0M | up to 9.3 s |
preserve_topology |
free (allow_junction_cleanup: true) |
0 | 6.76 (190246; next 1.5e-4) | 14 of 24 | 0.099 | up to 1.9M | up to 8.7 s |
The other warnings are the input's: duplicate segments (merged_segments, up to 4,144 on
202361) and overlapping ones (overlapping). No point is lost anywhere; every input feature
point reaches an output vertex but 9 of 584 on 226193 at default topology with junctions free,
where the input simplification without the link condition retires their vertices. Recording
costs 2.6–4.0% instructions at default settings with junctions free (12 models, serial), with
the output .msh byte-identical with recording off and on and to the parent commit's. Since
history_output anchors the junctions, recording changes the output unless the config gives
allow_junction_cleanup.
What breaks bijectivity¶
Each of these shows up as warnings, never as a refusal to run: - topology changes: welds of two surface vertices along a non-surface edge, sheets merging, collapses without the link condition; - surface changes outside the operations; - patches that are not disks.
For a bijective map, run tetwild with preserve_topology: true. It holds both the input
simplification and the tet operations to the link condition. (simplify_use_link_condition
is gone; tetwild and triwild warn if a config still sets it.)
What is left under preserve_topology (101168: 298 warnings, no welds):
- collapses near a non-manifold vertex the input has and keeps, whose star is not a disk;
- collapses where 4 to 12 feature curves meet. The curves cut the patch into one-face sides
the joint flattening refuses, and each such face is mapped onto itself by barycentric
coordinates. That is a bijection on the face, though possibly not consistent with its
neighbours along the curves; not looked into.
Extending the mapping component¶
- A new edit kind:
- Add it to
EditKindwith its semantics. - Implement it in
Replay::apply. - Write and read it in
History.cpp, bumping the version. - Give
SurfaceMap's constructor a case that builds aStep, and teachmap()'svisitto move points through it, both directions. - Add the warnings it can raise to
MapWarnings. - A new way of naming output points: if the application's output numbers vertices its own
way, set
output_vertex_ids.Trackingthen speaks in vertex triplets. Anything else -- tet ids and local faces, say -- belongs inTracking, converting to and from history face ids. - A new operation: add it to
app.cppand tooperation's options in the spec, with every new option documented there. - Another curve application. On a
TriOptimizerMesh,record_curve_historyandfinish_curve_historycome with it. Otherwise overrideis_tracked_edgeandtracked_feature_pointson yourTriMeshand callstart_curve_history(position). Keep the feature points by the rules (a collapse renames a removed one to the survivor), and start from the input withhistory::curves_from_inputif the input is curve files. - A curve edit kind goes through the same steps as above, in
CurveMapinstead ofSurfaceMap.split_faceandswap_edgehave no curve meaning, and loading a curve history that holds one fails.
Evaluating correctness¶
The checks used so far, from cheapest to strongest:
1. The replay reaches the output. Replay the history and compare its last state with the
application's output surface, by sorted corner positions.
- test_tetwild_history.cpp: the exported tracked faces.
- test_simwild_history.cpp: the output's tag interfaces.
- test_remeshing_history.cpp: isotropic.
2. The recorder recognised everything. TrackedSurfaceRecorder::stats():
- replaced = 0 and unknown = 0;
- welds = 0 under preserve_topology.
Also: no "feature edges ... are not the mesh's" warning at save.
3. The map builds without warnings. build_warnings().total() == 0; the tracking
info operation prints them, and the first offending edits.
4. Round trips. to_input(to_output(p)) == p, and the reverse, below 1e-8 in barycentric
coordinates. A map that is not a bijection misses by far more. So does one that stretches
a lot. To tell which, trace one point both ways, step by step, and replay the history
next to the traces, comparing the two in 3D after each edit. Compare in 3D, not by face
and barycentrics: a point on an edge has two names.
- The discrepancy should start at rounding (1e-14).
- A step that multiplies it by the same factor for every point stretches: that is the map.
- A step that multiplies it by about the square of a triangle's aspect ratio computes
badly. 101168 under preserve_topology had the embed doing that: barycentric
coordinates solved through the normal equations, on a sliver of aspect 3.5e4.
- The scaffolding for this was a temporary trace() on SurfaceMap. It is not in the
repository; it records pts[0] after each step in map().
5. A check the map does not see:
- simwild's tags: an output interface face maps to an input face between the same two tag
sets;
- for surface inputs: onto a file whose tag the interface separates;
- tetwild: two disjoint spheres from two files, each output face mapping onto the file of
its sphere.
6. Geometry. The image of a point lies near it: within about two input edge lengths in the
examples, or the envelope for tet applications. A linear attribute remapped through the
inverse map comes back close to itself.
7. Quality of the map. folded_cells() counts local maps that must stretch. For UVs,
compare the distortion of the transferred texture with the input's: flipped texture
triangles, and the conformal distortion of each triangle's 3D-to-UV map.
8. Curves. The same checks: the history's last segments, by output_vertex_ids, are edges
of the output mesh at the same positions; the recorder's replaced and unknown are 0 and
finish_curve_history warns about nothing; the map builds without warnings under
preserve_topology; round trips are at rounding (1e-16 on the examples, arc length being
exact); input feature points end at output vertices within eps of themselves; a point at a
curve's end maps onto the end's output vertex. Tests: test_curve_map.cpp (exact maps on
hand-built histories: a straight line is the identity, arc length on a bend, junctions and
loops, a weld, duplicates and an embed, the file format) and test_triwild_tracking.cpp.
9. Recording changes nothing. With deterministic (serial) runs, an application with
recording on, with it off, and current main must write byte-identical meshes.
- Compare instruction counts (/usr/bin/time -l) for the cost, not wall time.
- At 16 threads, runs differ in their iteration counts, which swamps the overhead in wall
time.
Folds¶
Remeshers that do not check normals fold the surface over locally. The map stays bijective
there, but stretches. Two exact checks, both counting folds the same way:
- isotropic_remeshing's check_inversions, on by default.
- TetOptimizerMesh's check_surface_folds (tetwild, simwild), off by default.
Both refuse a collapse, swap or smoothing if either of these grows: - the number of faces not facing the replaced patch's normal, or the new patch's own normal; - the folds in the stars of the vertices it touches.
Facing is exact: orient3d of the triangle and a point below it along the normal
(utils::predicates::faces_along).
The tet check (TetOptimizerMesh::tracked_surface_before/after, called by TetMesh's
operations through checks_tracked_surface()):
- Before: the tracked faces around the operation. A collapse takes those at either end;
a swap, those inside its ring; a smoothing, those at the vertex. It records the patch's
summed area vector, how many faces do not face along it, and the star folds over the
patch's vertices. It skips operations with no surface vertex.
- After, once the after-hook and invariants have passed: the same counts on the faces
there. More of either refuses the operation, like a failed after-hook, with a rollback.
- Splits are not checked: they halve faces and move nothing.
- Colored smoothing is off while the check is on. Colored smoothing moves vertices of a
class concurrently without locks, and the check reads the stars of a vertex's neighbours,
which two vertices of one class can share.
- Stars come from the vertex's own tets; see Locking.
What it does, from test_surface_folds.cpp (8 threads, 10 iterations, three runs each).
These are folded star faces after insertion and at the end; insertion already leaves many,
creases sharper than their star's average included:
| model | check off, at the end | check on, at the end | after insertion |
|---|---|---|---|
| bunny | 9–12 | 0–1 | 122–143 |
| crown | 35–77 | 13–31 | 1556–1801 |
| thingi_100036 | 87–117 | 15–25 | 195 |
| 37989_sf | 1851–1902 | 1681–1697 | ~3500 |
| Octocat | 108–117 | 65–73 | 158–199 |
On the octocat without simplification, folded local maps went from 3,456 to 112, for +5% final max energy. Keep it off by default (the user's decision, 2026-10-21).
Tests:
- tetwild-surface-folds-refused: a vertex moved past its star's outline is refused.
- tetwild-surface-folds-none-made: a parallel bunny run ends with no more folds than the
insertion left.
- [.folds-compare] (hidden): the table above.
Costs¶
Measured on the 15 challenging tetwild models (default parameters, so preserve_topology
off), serially, comparing recording on, recording off, and main (43d74ecfe0). Scripts:
exp_challenging/run_eval.py and reinfo.py in the session scratchpad, not in the repository.
- Recording off costs nothing. Output meshes are byte-identical to main's on all 15
models, with the same iteration counts. Instructions retired: -0.07% to +0.06% per model,
-0.04% in total. The hooks are one pointer test per operation.
- Recording on: +0.7% to +8.2% instructions per model, +4.4% in total. Wall time
+6% in total. Peak memory +1% to +39%; the most is on 46024, whose 320k-face input the
history keeps as read.
- History size: 0.5 to 38 MB; 35 to 80 bytes per edit, plus the input as read.
- 25.6 MB (750k edits) for the longest run, 1017020;
- 38 MB for 46024, mostly its input (190 bytes per edit).
- Building the map: 0.4 to 31 s, about a second per 25,000 edits.
- Map quality with preserve_topology off:
- there are always some not-a-disk and merged-face warnings (welds, sheets merging);
- 13 of the 15 models round-trip below 1e-3;
- 1017012 and 1017020 (3,000 and 4,450 welds) lose 5 and 9 output centroids and round-trip
up to the model's scale.
Check memory by peak footprint (/usr/bin/time -l). At 16 threads, wall times vary with the
iteration counts, and other load moves them too. Compare instruction counts.
Where things are¶
| what | where |
|---|---|
| history, edit kinds, file format, composition | src/wmtk/history/History.{hpp,cpp}, Fwd.hpp |
| replay | src/wmtk/history/Replay.{hpp,cpp} |
| tracked-surface and tracked-curve recorder | src/wmtk/history/TrackedRecorder.{hpp,cpp} |
| input chains | src/wmtk/history/InputHistory.{hpp,cpp} (from_input, curves_from_input) |
| TriMesh hooks | src/wmtk/TriMesh.{h,cpp} (start_history, commit_history; start_curve_history, curve_history_commit) |
| TriOptimizerMesh side (curves) | src/wmtk/TriOptimizerMesh.{h,cpp} |
| curve read provenance, simplification, insertion sources | src/wmtk/io/read_edge_mesh.{hpp,cpp}, src/wmtk/utils/SimplifySegments.{hpp,cpp}, src/wmtk/utils/EmbedSegments.{hpp,cpp} |
| TetMesh hooks | src/wmtk/TetMesh.{h,cpp}, TetMeshEdgeSplittingConn.cpp, TetMeshEdgeCollapsingConn.cpp, TetMeshSwapMeshConnectivity.cpp |
| TetOptimizerMesh side, fold check | src/wmtk/TetOptimizerMesh.{h,cpp}, OptimizerParameters.h |
| read provenance | src/wmtk/io/read_triangle_mesh.{hpp,cpp} |
| insertion sources | src/wmtk/utils/EmbedTriangles.{hpp,cpp} (facet_triangles) |
| map, transfer, tracking, app | components/tracking/wmtk/components/tracking/ |
| isotropic recording | components/isotropic_remeshing/.../isotropic_remeshing.cpp |
| tetwild recording | components/tetwild/.../tetwild.cpp, VolumemesherInsertion.cpp |
| simwild recording | components/simwild/.../simwild.cpp, EmbedSurface.cpp, read_image_msh.cpp, SimWildMesh.cpp |
| triwild recording | components/triwild/.../triwild.cpp |
| volume history, recorder, shared binary encoding | src/wmtk/history/VolumeHistory.{hpp,cpp}, BinaryIO.hpp; TetMesh's start_volume_history and captures in TetMesh.cpp |
| volume map, its patches and geometry, VolumeTracking | components/tracking/.../VolumeMap.{hpp,cpp}, volume_complex.{hpp,cpp}, volume_geometry.{hpp,cpp}, volume_io.{hpp,cpp}, tracking.hpp; the app's volume_app.cpp |
| Python classes | python/src/tracking_bindings.cpp; tests in tests/python/test_tracking.py |
| tests | tests/test_history.cpp; test_surface_map.cpp, test_curve_map.cpp, test_tracking.cpp, test_volume_map.cpp, test_volume_ops.cpp; test_remeshing_history.cpp; test_tetwild_history.cpp, test_tetwild_tracking.cpp; test_simwild_history.cpp, test_simwild_tracking.cpp; test_triwild_tracking.cpp |