simwild: behaviours adopted from tetwild / triwild¶
tetwild and triwild are the mature, heavily-measured applications; simwild is a fork of them that drifted. When de-duplicating, the rule is: wherever the copies disagree, the tetwild/triwild implementation wins, and tetwild/triwild output must not move. simwild output may move, and every time it does the reason is recorded here so the simwild side can be re-checked later.
Columns: what simwild did, what it does now, which copy won, and what actually moved.
Stage 1 — divergence defects¶
1a. SimWildMeshTri::swap_all_edges returns the success count¶
| was | return true; — i.e. always 1, whatever happened |
| now | returns total_success from run_localized_to_convergence, and the pass uses parallel_collect_edge_ops + localized retry |
| source | triwild EdgeSwapping.cpp::swap_all_edges, which carries the fix and the note |
| why it matters | local_operations stops the swap loop when a pass changes nothing (if (cnt_success == 0) break;). Returning a constant 1 made that early exit unreachable. |
| measured | No output change on any of the 8 simwild 2D configs. The loop is bounded by ops[2], which is 1 in every current config, so there was never a second round for the early exit to skip. The defect is real but latent — like the split fallback in #1000, it needs a config that asks for more than one swap round per iteration. |
| also adopted | parallel_collect_edge_ops and run_localized_to_convergence, which simwild-2D used nowhere. Output-neutral here because the pass converges in one round on these inputs. |
1b. SimWildMeshTri reports why smoothing was refused¶
| was | m_smooth_rejects declared and passed to smooth_vertex_2d, but never reset() and never logged |
| now | reset at the top of each smoothing pass, to_string() logged at the end, as triwild does |
| source | triwild Smooth.cpp::smooth_all_vertices |
| why it matters | The counters exist precisely to catch silent failure — SmoothVertex.hpp:222-228 records tetwild smoothing ZERO vertices for entire runs without anything noticing. Accumulating them across passes and never printing them defeats that. |
| measured | No output change; diagnostics only. |
1c. simwild-3D skips the Voronoi search for an un-rounded vertex¶
| was | if (m_voronoi_split_fn) |
| now | if (m_voronoi_split_fn && m_vertex_attribute[v_id].m_is_rounded) |
| source | the 2D twin, SimWildMeshTri::split_edge_after, which already carried the guard and the reasoning |
| why it matters | When the rounded midpoint inverts a tet, the split falls back to the exact rational midpoint. The Voronoi bisection then searches only doubles — including the plain double midpoint it reverts to on failure, which is the position that just inverted. Neither tetwild nor triwild has a Voronoi split, so this is simwild-internal: the 2D copy was right and the 3D one had not been updated. |
| measured | No output change: no current config sets m_voronoi_split_fn and reaches the fallback. |
1d. SimWildMesh::swap_edge_56_before stores the max energy it computes¶
| was | computed max_energy over the incident tets and dropped it, leaving cache.max_energy holding whatever the previous swap op wrote |
| now | swap_cache.local().max_energy = max_energy; |
| source | tetwild EdgeSwapping.cpp:756 |
| why it matters | swap_cache is one thread-local shared by the 3-2 / 4-4 / 5-6 ops. Harmless only as long as swap_edge_56_after never reads it — but the sibling swap_edge_44_after and swap_edge_after both do. |
| measured | No output change. |
1f. simwild-3D routes swap surface detection on the incident-face count¶
| was | is_edge_on_surface(t), which short-circuits on both endpoints' m_is_on_surface flags |
| now | edge_incident_surface_face_count(t) > 0, ported from tetwild; applied at all three swap sites (3-2, 4-4, 5-6) |
| source | tetwild EdgeSwapping.cpp:144-151 and its comment |
| why it matters | A stale m_is_on_surface flag makes a genuine surface edge look interior, and swapping it tears the surface. The face-count route consults only the face attributes. |
| measured | No output change on the 6 simwild 3D configs — the flags are not stale on these inputs. |
| not changed here | simwild still rejects surface edges in 4-4 and 5-6 where tetwild allows a surface flip via its generalized prepare_surface_flip. That is a larger decision, deferred to the 3D base-class merge. |
1e. The collapse quality gate — NO CHANGE, the premise was wrong¶
The duplication survey reported "four mutually incompatible spellings" of the quality gate in
collapse_edge_before:
tetwild VA[v1_id].m_is_rounded && q > cache.max_energy
simwild-3D m_collapse_check_quality && VA[v1_id].m_is_rounded && q > cache.max_energy
triwild VA[v1_id].m_is_rounded && q > m_params.stop_energy && q > cache.max_energy
simwild-2D VA[v1_id].m_is_rounded && q > target_quality(tid) && q > cache.max_energy
Checked before changing anything, and they are two rules, not four — one per dimension, each correctly adapted in simwild:
SimWildMeshTri::target_quality(tid)defaults tom_params.stop_energyand is only overridden by the per-tagquality_field. So simwild-2D's gate is triwild's, generalized to per-region targets. Replacing it withstop_energywould delete a simwild feature, not fix a divergence.m_collapse_check_qualityis an extra simwild-only toggle (switched off aroundsimplify()), not a changed rule. simwild-3D's gate is otherwise tetwild's exactly.
The real open question is tetwild vs triwild. tetwild never permits a collapse that worsens the ring's worst element; triwild permits it as long as the result is still below the target. They disagree with each other, so "take the tetwild/triwild implementation" cannot resolve it. Left alone pending a decision and a measurement. See also 1i, the other tetwild/triwild disagreement.
1g. simwild-2D uses the shared utils::SizingField helpers¶
| was | refine_sizing_around_worst and gradation_smooth_sizing hand-rolled the top-N selection, the BFS region growth, the refinement clamp and the Dijkstra min-relaxation — ~100 lines re-implementing src/wmtk/utils/SizingField.hpp, which the other three meshes call |
| now | utils::select_worst_cells, grow_vertex_region, apply_sizing_refinement, gradation_smooth_sizing; 92 lines deleted |
| source | simwild-3D's refine_sizing_around_worst, which already had this shape |
| measured | No output change on any config. |
Semantics deliberately preserved, not "corrected" to triwild's. triwild filters worst cells
on an absolute filter_energy = min(max(max_energy/100, stop_energy), 100.); simwild filters on
relative quality against the per-cell target_quality(tid) at a threshold of 1.0. That is
simwild's model — its whole stop condition is per-cell relative quality — and simwild-3D already
expresses it that way through the same shared helper. The hand-rolled 2D filter
(if (q < target_quality(tid)) continue;) is exactly equivalent to it.
Still missing in simwild-2D, not addressed here (a feature port, not de-duplication):
force-split of the worst cells' longest edges (m_force_split_edges), which tetwild, triwild and
simwild-3D all have. skip_good_regions / active_vertices were adopted with the shared 2D
smoothing pass in Stage 5 below.
Decisions taken, not applied¶
Two divergences turned out to be tetwild vs triwild, where "take the tetwild/triwild implementation" has no answer because the two reference apps disagree with each other.
The collapse quality gate (see 1e) — deferred, decide with measurement¶
tetwild and simwild-3D never permit a collapse that worsens the ring's worst element; triwild
and simwild-2D permit it while the result stays below the target. Each fork already matches its
own sibling, so nothing is inconsistent within a pair. Answering which rule is better is an
optimizer-behaviour question needing a sweep of both variants on the challenging set, not a
de-duplication question. Left as is. The 3D/2D base-class merge should surface it as one
named virtual (e.g. collapse_allows_worsening_below_target()) so the choice is explicit in one
place instead of implicit in four.
tetwild's stale m_posf after the exact-midpoint split — separate PR¶
triwild, simwild-3D and simwild-2D all set p = to_double(m_pos) after falling back to the
exact rational midpoint; tetwild alone keeps the average of the two endpoint doubles. triwild
documents why its version is better: when an endpoint is itself un-rounded, rounding the exact
midpoint once beats averaging two approximations.
Fixing tetwild necessarily moves tetwild output, which is the signal every de-duplication stage is validated against. Deferred to its own PR after the refactor, where a real output change is the expected result rather than a warning sign.
Stage 1h — Parameters struct defaults now match their own spec defaults¶
Fourteen fields had a C++ struct default that disagreed with the JSON spec default for the same
key, so any code path that builds Parameters without going through jse — which is what the
unit tests do — ran a different configuration than every driver run:
| app | field | struct was | spec (now both) |
|---|---|---|---|
| tetwild | eps_rel |
2e-3 | 1e-3 |
| tetwild | stop_energy |
10 | 100 |
| tetwild | interleaved_smoothing |
false | true |
| tetwild | interleaved_smoothing_passes |
2 | 1 |
| tetwild | stuck_refine_num_worst |
50 | 0 |
| tetwild | stuck_refine_rings |
3 | 0 |
| tetwild | skip_good_regions |
true | false |
| triwild | stop_energy |
20 | 100 |
| simwild | skip_simplify |
true | false |
| simwild | eps_simplify_rel |
2e-3 | 2e-4 (10x) |
| simwild | preserve_topology |
false | true |
| simwild | stuck_refine_num_worst |
50 | 0 |
| simwild | stuck_refine_rings |
3 | 0 |
| simwild | skip_good_regions |
true | false |
The spec is authoritative: it is what every driver run injects. No integration output moved,
which is the expected result — the configs all go through jse. ctest 107/107, so the unit tests
that construct Parameters directly either set these explicitly or are insensitive to them.
Stage 2 — extractions¶
report["#t"] counted faces, not tets, in simwild 3D¶
| was | report["#t"] = mesh.get_faces().size(); in run_3D — on a TetMesh that is the number of faces |
| now | mesh.get_tets().size() |
| source | tetwild reports tet_size(), triwild reports tri_capacity(); both mean cells |
| measured | The 6 simwild 3D configs report #t roughly halved — e.g. simwild_double_sphere_3d 22456 -> 10897, a ratio of 2.06, which is what a closed tet mesh gives since each interior face is shared by two tets. The mesh is unchanged; only the reported number was wrong. |
run_2D was already correct: on a TriMesh the faces are the cells.
Note for future baselines: simwild 3D #t values recorded before this change are face counts.
Stage 3a — simwild-2D adopts wmtk::TriOptimizerMesh¶
SimWildMeshTri now derives from the shared 2D base rather than from wmtk::TriMesh, and its
copies of the three attribute types, the shared members and 13 methods are deleted in favour of
the base's.
No simwild output moved, and no behaviour was adopted. This is the rare case where the policy ("when the two disagree, tetwild/triwild wins") had nothing to decide, because the two copies did not disagree anywhere. Every difference between simwild-2D's implementation and triwild's was cosmetic, and each was checked individually before the copy was deleted:
| function | the only difference |
|---|---|
is_inverted(array) |
const auto res vs auto res; return !(res > 0) vs an if/else; triwild carries a stale comment about orient3d that simwild had dropped (dropped here too) |
is_inverted(Tuple), is_inverted(size_t) |
simwild inlines the oriented_tri_vids temporary |
get_quality(Tuple), get_quality(size_t) |
same inlining |
get_quality(array) |
none — identical |
is_inverted_f |
simwild has only the size_t overload; the bodies match. It now also inherits the Tuple one |
round |
m_pos << posf[0], posf[1] vs to_rational(m_posf), which are the same value; both set m_is_rounded before the one-ring check. simwild's comment explaining why was the better one and was kept |
get_length2 |
get_edge_vids vs switch_vertex, same two vertices |
partition_mesh |
auto i vs size_t i as the loop variable (simwild's compares signed to unsigned) |
round_all_vertices, get_max_avg_energy, gradation_smooth_sizing, get_edges_by_condition, is_edge_on_surface x2, is_edge_on_bbox x2, vertex_is_on_surface, edge_is_on_surface |
none — identical after normalisation |
The attribute types matched too: CellTag is a std::set<int64_t>, so FaceAttributes was already
character-identical, and VertexAttributes differed only by triwild's m_feature_id, which
simwild-2D leaves at NO_FEATURE.
Measured: all 53 registered configs byte-identical against the run immediately after the move
commit — every simwild config included — plus ctest 107/107. tetwild_crown and tetwild_octocat
run 10 threads and are nondeterministic there, so they were compared separately at num_threads: 0
against a build of the pre-refactor commit: byte-identical.
One thing to watch later: SimWildMeshTri keeps its own round_and_check_all_rounded,
m_exact_split_count and triangle_area, none of which triwild has. The first two are the
subject of Stage 4's RationalPositions mixin.
Stage 3b — simwild-3D adopts wmtk::TetOptimizerMesh¶
SimWildMesh now derives from the shared 3D base, dropping its copies of VertexAttributes,
FaceAttributes, the vertex and face attribute collections, the envelope/solver/rounding members,
MAX_ENERGY, and 23 method definitions.
Of the 21 methods the base owns, 19 were already byte-identical between the two applications and
two (get_quality, output_faces) differed only cosmetically -- auto vs bool for a local, and an
(int) cast on a row assignment into a matrix that is already int. That leaves exactly one
behavioural adoption:
round() now marks the vertex rounded before the one-ring check¶
| was | simwild left m_is_rounded false until every incident tet had been checked, so is_inverted took the exact rational branch throughout the check |
| now | tetwild's: m_is_rounded is set before the loop and cleared again on failure, so is_inverted takes the float branch |
| source | tetwild, triwild and simwild-2D all already did it this way; simwild-3D was the only one that did not |
This is answer-preserving, not merely close. The check asks whether the ROUNDED position keeps
every incident tet valid. Under the old order the vertex's m_pos had already been set to
to_rational(m_posf) -- exactly the double, as a rational -- so the rational branch evaluated the
orientation of the same four points that the float branch evaluates with orient3d, which is itself
exact for the doubles it is handed. Both branches also fall back to rational identically when some
other incident vertex is un-rounded, since is_inverted tests all four flags. The difference is
cost, not result: simwild was paying for exact arithmetic to compute an answer the float predicate
already decides exactly.
Not adopted in this commit¶
The swap family stays per-application and is untouched here: swap_face_before, swap_face_after,
swap_edge_after and swap_all_faces all propagate simwild's CellTag through the operation, which
tetwild has no counterpart for. Sharing them needs a tag-propagation hook. Three differences found
while measuring them are recorded here so they are not lost:
swap_all_facescollects the wrong simplex. simwild callsparallel_collect_edge_opsand queues"face_swap"operations on edge tuples -- 6 per tet -- where tetwild callsparallel_collect_face_opsand queues them on faces, 4 per tet. The face-swap pass therefore enumerates a different set, with each face reachable from up to three of its edges.- simwild calls the executor directly where tetwild uses
run_localized_to_convergence. - simwild reads the success count from
executor.get_cnt_success()after the lambda has returned; tetwild accumulates it into a captured local.
At this stage smoothing_energy_envelope stayed per-application. Stage 6 below subsequently
removed SimWild's redundant surface-envelope alias and adopted TetWild's order->= 2 selection;
only the application-specific order-2 envelope object remains virtual.
smooth_after stays in both applications even though their bodies are byte-identical: the body
is the shared optimization::smooth_vertex_3d template, which reaches m_tet_attribute and
smoothing_energy_envelope, both per-application.
Stage 3c — simwild adopts wmtk::run_pass¶
The if (NUM_THREADS > 0) / build an ExecutePass / install a lock / log the time shell around
every driver is now wmtk::run_pass, shared by all four meshes. No simwild behaviour changed:
the shell was already identical to tetwild's and triwild's in all eleven simwild drivers, and what
each driver does inside the executor is passed in as a callback, so the differences below survive
the fold untouched rather than being silently adopted.
They are listed here because folding the shell is what makes them visible, and because each one is now a one-line change instead of a rewrite:
| simwild | tetwild / triwild | |
|---|---|---|
| how the pass runs | executor(mesh, ops) once, in 3D split, all four 3D swaps and 2D split/smooth |
run_localized_to_convergence(mesh, executor, ops) — retries a failure only where the mesh actually changed that round |
| 2D collapse | a hand-rolled do { ... } while (executor.get_cnt_success() > 0) that rebuilds the whole op list from get_edges() every round |
the same localized retry as everything else |
| success count | executor.get_cnt_success(), i.e. the last round's count |
the total accumulated across rounds by the retry driver |
The success-count row matters where the caller uses the return value to decide whether to keep
going: local_operations stops its swap loop once a pass changes nothing, and a per-round count and
a total disagree about when that is. simwild-2D's swap already takes the shared form (Stage 1a); the
3D swaps do not.
Adopting the localized form is a real behaviour change for simwild -- it changes which failed operations are re-tested and in what order -- so it wants its own commit and its own before/after, not a line in a refactor.
Post-3c — simwild adopts the localized retry and the accumulated success count¶
Closes the three differences Stage 3c recorded but deliberately left alone. Every driver now runs its pass the way its sibling does.
| driver | was | now |
|---|---|---|
| 3D split, all five 3D swaps, 2D split | executor(mesh, ops) — a single pass, so an operation rejected early was dropped even when a later success next to it made it viable |
run_localized_to_convergence, which re-runs until a pass yields nothing, re-queueing only failures adjacent to a vertex that moved that round |
| 2D collapse | do { all_ops.clear(); for (e : get_edges()) …; executor(…); } while (get_cnt_success() > 0) — converged, but by re-enumerating every edge and re-running the full geometric pre-check on each one, every round |
the same shared retry driver, off an op list built once |
| the five 3D swaps' return value | executor.get_cnt_success(), the last round's count |
the total accumulated across rounds |
The return value is not bookkeeping: local_operations stops its swap loop when a pass reports
zero, and a pass that has converged ends on a zero round by construction, so the old form made the
loop stop after one round of swaps regardless of how much work remained.
The 2D collapse needed a renew_neighbor_tuples — its rebuild-everything loop never had one, and
the retry driver takes the modified region from exactly that callback. It is triwild's.
Not adopted here, still open: simwild builds its op lists with a serial
for (const Tuple& e : get_edges()) where tetwild and triwild use parallel_collect_edge_ops. That
changes the queue's insertion order, which breaks ties in the priority queue, so it is its own
behaviour change and would have blurred this one's before/after.
Measured¶
Four configs moved; every triwild and deterministic tetwild config is byte-identical, and the branch
touches no file outside components/simwild/.
| config | before | after |
|---|---|---|
simwild_curves_2d |
572 tris, avg 2.385, max 8.294 | 384 tris, avg 2.499, max 8.494 |
simwild_curves_two_inputs_2d |
465 tris, avg 3.343, max 18.862 | 871 tris, avg 2.815, max 18.844 |
simwild_remeshing_2d |
211 tris, avg 2.299, max 4.330 | 246 tris, avg 2.324, max 4.188 |
simwild_double_sphere_notop_3d |
21454 tets, avg 3.6171 | 21459 tets, avg 3.6170 |
The direction is mixed, and the mechanism is visible in simwild_curves_2d's log. The split pass
now converges instead of running once, so the mesh refines far harder mid-run -- 1955 edges at
iteration 5 where it used to reach 695 -- and the collapse pass then pulls it back further than
before, to 378. The optimizer takes a different trajectory rather than a uniformly finer or coarser
one. Collapse itself is unchanged in aggregate: the first call's rounds used to sum to ~316
successes and the shared driver now reports 313.
Post-3c — simwild adopts parallel_collect_edge_ops¶
The last collection-side difference. simwild built three op lists with a serial
for (const Tuple& e : get_edges()) -- 3D split, 2D split, 2D collapse -- where tetwild and triwild
use the shared parallel builder. Held back from the localized-retry change because it alters the
queue's insertion order, which is what breaks ties between equal-priority operations, and mixing
the two would have made neither before/after attributable.
Measured: nothing moved. All 53 registered configs are byte-identical, simwild's four included -- the same four that moved for the retry adoption. The tie-breaking concern is real in principle and did not materialize on any config in the suite.
That leaves simwild's op collection identical to its sibling's everywhere except swap_all_faces,
which is still parallel_collect_edge_ops where tetwild uses parallel_collect_face_ops -- that
one is a defect rather than a style difference (6 edge tuples per tet instead of 4 faces) and is
recorded above with the rest of the swap family.
Post-3c — swap_all_faces collects faces¶
The defect recorded with the swap family, fixed on its own because it is a behaviour change rather than a sharing question.
SimWildMesh::swap_all_faces called parallel_collect_edge_ops and queued "face_swap" operations
on edge tuples -- 6 per tet, with each face reachable from up to three of its edges -- where
tetwild calls parallel_collect_face_ops and queues them on faces, 4 per tet. The pass therefore
enumerated a different set of simplices from the one it is named for, seeding the priority queue
with duplicates of some faces and a different traversal for the rest.
Two configs move, both of them the 3D ones that actually run face swaps:
| config | before | after |
|---|---|---|
simwild_double_sphere_3d |
— | same #t/#v, different connectivity |
simwild_double_sphere_notop_3d |
21459 tets, avg 3.616959 | 21528 tets, avg 3.617020 |
Everything else in the suite is byte-identical, and the branch touches no file outside
components/simwild/.
Both remaining swap differences are settled below.
Post-3c — simwild adopts tetwild's swap family, with tag propagation¶
The last item in the de-duplication programme, and the largest single behaviour change in it.
components/simwild/.../EdgeSwapping.cpp is now tetwild's file: after renaming the mesh class and
the parameters struct, the only lines that differ are the tag handling described below and one
swap_all_faces comment. Every remaining divergence has been resolved in tetwild's favour.
Why tags and the surface are the same question¶
simwild's surface is not an input mesh — init_surfaces_and_boundaries tags a face
m_is_surface_fs exactly when its two tets carry different tags. So a surface diagonal flip in
simwild is a flip of the tag interface, and the volume it sweeps (the tet (a,b,c,d) between the
old and new diagonal) moves from one tag to the other. That is the same thing tetwild's flip does
across its inside/outside surface, and it is gated the same way, by the Hausdorff envelope check on
the two new faces.
Two consequences fall out and are used throughout:
- An edge with no incident surface face has all its incident tets identically tagged, so an interior swap has nothing to decide.
- The two surface faces
(a,b,c),(a,b,d)cut the ring of incident tets into two arcs, one tag each — and every tet a flip creates contains at least one ring vertex other thanc,d, which says which arc, hence which tag, it belongs to.
What simwild adopted¶
| before | after | |
|---|---|---|
prepare_surface_flip |
prepare_surface_flip_32: exactly-3 ring only, log_and_throw_error on anything unexpected, asserts that (c,d) already exists |
tetwild's, generalized to any ring size; rejects rather than throws; handles (c,d) not existing yet, which is the normal case for 4-4 and 5-6 |
| 4-4 and 5-6 on a surface edge | rejected outright (edge_incident_surface_face_count > 0 → false) |
steered to the case that realizes the flip, by swap_edge_44_accept_case / swap_edge_56_accept_case, then envelope-checked and re-tagged like the 3→2 case |
the (c,d) non-manifold guard |
assert + get_surface_faces_for_edge |
tetwild's direct enumeration, which does not depend on the possibly-stale m_is_on_surface vertex flags |
| the "would-be new faces already surface" guard | absent | tetwild's, gated on lowest_common_tet so a Debug build does not abort on every 4-4 flip |
swap_edge_56_after |
re-checked is_inverted |
tetwild's: swap_edge_56_energy returns double::max() for any inverted candidate and the case is only taken when strictly below the old energy, so the check could never fire |
swap_all_edges_44 / _56 |
no check_surface_topology guard |
tetwild's guard, now that these can change the surface |
swap_all_edges_all |
logged its topology warning as "swap_all_edges_32" |
its own name |
| counters | cnt_surface_swap only |
plus tetwild's cnt_surface_swap_32/44/56 breakdown, and the same log line |
| open-boundary edges | not considered | wmtk::TetOptimizerMesh::is_open_boundary_edge, a new virtual that defaults to false and that tetwild overrides — see below |
is_open_boundary_edge defaulting to false is exact for simwild, not a stub: around an interior
edge the tags change an even number of times going around the tet ring, so the interface can never
terminate there; it can only end where it meets the bbox, and is_edge_on_bbox has already
rejected those edges by the time the question is asked. Making it a virtual is what leaves the two
prepare_surface_flip bodies byte-identical.
The tag rule¶
Three simwild-only additions, and nothing else:
cache_interior_swap_tag— no incident surface face, so record the one tag the incident tets share. If they disagree the swap is refused: that can only happen if the tag/surface invariant is already broken elsewhere, and re-tagging would move tagged volume with no envelope check to gate it. This replaces the old 3→2 rule (majority vote over at most two tags) and the old 4-4 / 5-6 rule (takeincident_tets[0], with a commented-out check that they all agree).prepare_surface_flipadditionally recordsring_tags: for each ring vertex other thanc,d, the tag of its side.propagate_swap_tags— interior swap: give every new tet the cached tag. Surface flip: give each new tet the tag of the arc its ring vertices identify, and refuse if a tet straddles both arcs or touches neither.
On a well-formed mesh this leaves the 3→2 result unchanged: an interior edge has one tag, so the majority vote agreed with it, and on a surface edge the majority is by construction the arc holding two of the three tets, which is the arc both new tets land in.
SwapInfoCache::sf_e is gone — the third apex of the 3→2 ring, which nothing read.
Measured¶
tetwild and triwild are byte-identical, on all 53 registered configs — with tetwild_crown and
tetwild_octocat re-run at num_threads: 0, since at their configured 10 they are nondeterministic
by construction — and on all 30 hidden [challenging] models (14 tetwild Thingi10K, 16
triwild20k), compared against two independent baselines: the run for #1011 and the run for #1009,
which also agree with each other. The comparison is every report.json field except the timing
keys, plus the md5 of every emitted mesh — out_final.msh, out_simplified_input.obj,
out_surface.obj for tetwild and out.msh, out.vtu, out_surf.vtu for triwild.
Two simwild configs move, the only two that run 3D swaps:
| config | before | after |
|---|---|---|
simwild_double_sphere_3d |
10897 tets, 2198 verts, avg 3.80078 | 10888 tets, 2197 verts, avg 3.79623 |
simwild_double_sphere_notop_3d |
21528 tets, 4257 verts, avg 3.61702, max 8.5319 | 21311 tets, 4215 verts, avg 3.59206, max 8.7158 |
Both get slightly smaller at slightly better average energy, and the counters say exactly why:
| config | surface flips before | surface flips after |
|---|---|---|
simwild_double_sphere_3d |
4 | 85 = 4 (3→2) + 69 (4-4) + 12 (5-6) |
simwild_double_sphere_notop_3d |
0 | 89 = 1 (3→2) + 70 (4-4) + 18 (5-6) |
The 3→2 count on the first config is unchanged, which is the claim above about the tag rule
being behaviour-preserving there, measured rather than argued. All of the movement is the 4-4 and
5-6 flips that simwild used to refuse outright — the ones that let the optimizer relieve a bad
configuration on the interface instead of routing around it. (notop's 3→2 going 0 → 1 is
downstream: the mesh reaches configurations it never used to.)
Re-running both with check_surface_topology: true — which takes a full topological signature of
the tracked surface (V, E, F, Euler, components, boundary loops) before each swap pass and reports
any change afterwards — gives zero complaints across all 174 flips. A diagonal flip is supposed
to leave surface topology alone, and on real data it does.
Tested¶
test_simwild_swap_tags.cpp builds an N-tet ring whose two arcs carry different tags, derives the
surface from the tags the way init_surfaces_and_boundaries does, runs the flip, and checks that
every face is surface iff its two tets still disagree — for the 3→2, 4-4 and 5-6 flips, plus an
interior swap that must preserve its single tag. The 4-4 and 5-6 cases assert the resulting tets by
vid, so a wrong side would fail rather than merely a wrong count. Release and Debug.
Writing it turned up an unrelated latent defect in simwild's test-only create_mesh_attributes,
fixed here: it assigned m_tet_attribute.m_attributes a vector sized to the live tet count,
shrinking the collection below the connectivity capacity that init() had reserved. An
AttributeCollection deliberately never grows during an operation, so a 5→6 swap indexed one past
the end. tetwild carried the same bug and had already fixed it (ASan found it there); this is the
same fix, and simwild's version segfaults outright rather than corrupting quietly because the field
being written is a std::set and not a double.
2D was already done¶
For completeness, since "adopt all the swaps" covers both dimensions: the 2D swap family
(swap_all_edges, swap_weight, swap_edge_before, swap_edge_after) is already identical
between triwild and simwild-2D — after renaming the mesh class, the only differences are two
comments. Stage 3a's shared TriOptimizerMesh::FaceAttributes carries tags for both, so even the
tag lines are common code, and SwapInfoCache::face_tags is triwild's too. (triwild has carried an
unused std::set<int64_t> tags per triangle since well before this programme — see
git show origin/main:.../TriWildMesh.h — so that is pre-existing, not something the move added.)
The tag propagation is correct there for a simpler reason than in 3D: the 2D swap opens with
if (is_edge_on_surface(t)) return false;, and in 2D too the surface is the tag interface
(SimWildMeshTri::init_surfaces_and_boundaries), so a 2D swap only ever runs on an edge whose two
faces already agree. cache.face_tags = FA[incident_faces[0]].tags is then exact. There is no 2D
counterpart to the 4-4 / 5-6 question because a 2-2 flip is the only edge swap a triangle mesh has.
Stage 4 — one swap engine, with serial Wild/Sim conformance tests¶
The identical implementations described above are no longer merely kept in sync:
- TriWild's complete 2D swap pass now lives on
TriOptimizerMesh. BothTriWildMeshandSimWildMeshTriinherit the same collector, priority calculation, retry/termination logic, before/after callbacks, quality gate, attribute tracker and face-tag propagation. - TetWild's complete 3D swap family now lives on
TetOptimizerMesh: 3→2, 4→4, 5→6, 2→3 face swaps, the combined pass, surface-diagonal handling and topology diagnostics. SimWild's old 858-line copy is a small adapter implementing only three tag hooks: cache one homogeneous interior tag, cache the two surface-ring arcs, and propagate those tags to new cells. - TetWild's 3D smoothing pass and acceptance callback also live on
TetOptimizerMesh. The only application policy left virtual is which envelope supplies the surface energy/containment check. Quality reads and writes go through the common cell-quality accessors, so TetWild's pass ordering and rejection rules are used unchanged with SimWild's tagged cell attributes.
test_wild_conformance.cpp enforces the intended oracle relationship directly. It constructs two
identical meshes, runs the Wild implementation first and tag-homogeneous SimWild second with
NUM_THREADS == 0, then compares operation counts, canonical connectivity, per-cell quality and
vertex positions; the SimWild side must additionally preserve its homogeneous tag. There is one
fixture for TriWild/SimWild 2D and one for TetWild/SimWild 3→2. These are deliberately serial: a
parallel A/B test can turn shared global state or nondeterministic scheduling into noise and does
not establish that SimWild followed the Wild path. A third fixture runs the shared TetWild/SimWild
3D smoothing pass serially and compares the same geometry, exact positions and qualities while
checking that SimWild's cell tags are untouched.
Two audit fixes were made alongside the extraction:
stop_at_floatnow applies to SimWild 2D as well as 3D.- the public 3D
replace_tagsrejects mismatched input/output vector lengths, matching its 2D counterpart instead of indexing pastfound_tags.
Stage 5 — one 2D smoothing pass¶
TriWild's complete smoothing pass now lives on TriOptimizerMesh, matching the already-shared 3D
pass on TetOptimizerMesh. Both TriWildMesh and SimWildMeshTri inherit the same vertex
collection/order, rounding and bbox gate, two-stage solve, inversion/envelope/quality rejection,
quality updates, rejection counters, and skip_good_regions selection. The shared base also owns
the identical floating/exact position reads and writes.
Both applications use the base's single m_envelope for the pull energy and containment. Its
geometry is application-specific -- TriWild builds it around its input curves, while SimWild can
build it around the interface between different cell tags -- but there is only one envelope per
2D mesh. The only smoothing acceptance policy left virtual is the positional constraint: TriWild
uses it to preserve feature points and SimWild, which has no separate 0-dimensional features,
always accepts it.
Two SimWild behaviors deliberately move to TriWild's ground truth:
- SimWild no longer sets
quality_veto_on_surface = false; a surface move that worsens the worst incident triangle is rejected in both applications. - SimWild now honors
skip_good_regions; a vertex incident only to triangles belowskip_good_regions_margin * stop_energyis not scheduled for smoothing. - SimWild's unused 2D
m_envelope_origalias is deleted. Energy and containment both usem_envelope, exactly as in TriWild. This does not change the standard remeshing path because the two pointers aliased the same object there.
The serial conformance fixture runs TriWild first and tag-homogeneous SimWild second from identical
2D meshes, then compares connectivity, floating and exact positions, per-face qualities, and tags.
It separately enables skip_good_regions, makes every face good, and verifies both applications
schedule zero vertices. Full TriWild and SimWild test suites pass.
Stage 6 — envelope and refinement parity¶
SimWild 3D now has the same envelope model as TetWild: one surface m_envelope, plus the separate
order-2 feature envelope. The old m_envelope_orig pointer was always assigned from m_envelope
and is deleted. Feature pulling now uses TetWild's exact condition, order >= 2, including
order-3 junction vertices, and falls back to the surface envelope when no initialized feature
envelope exists.
The remaining refinement gaps are also closed:
- SimWild 2D records the longest edge of each selected worst triangle in
m_force_split_edges, matching TriWild. - SimWild 3D applies TetWild's oversized-only force-split guard, including the JSON option.
active_vertices()is virtual at the optimizer bases. Wild keeps its absolute global threshold; SimWild overrides it by normalizing every cell against its tag-dependenttarget_quality().
The serial conformance suite now also covers 3D 4→4, 5→6, 2→3, and combined swaps; order-3 feature-envelope selection; 2D/3D force-split refinement; per-tag active-region filtering; and heterogeneous-tag split/collapse invariants in both dimensions.
Stage 7 — shared outer optimization drivers¶
The outer schedules now live on the dimensional optimizer bases:
TetOptimizerMeshowns the complete TetWild/SimWild 3D driver and itslocal_operationspass.TriOptimizerMeshowns the complete TriWild/SimWild-2D driver and itslocal_operationspass.- the smoothing placement parameters (
num_smoothing_passes,interleaved_smoothing, andinterleaved_smoothing_passes) live inOptimizerParameters, so all three components configure the same schedule.
TetWild and TriWild remain the behavioral sources of truth: pre- and post-collapse, interleaved or batched split/collapse/swap/smooth ordering, per-phase convergence checks, rounding, consolidation, stuck refinement, swap termination, debug output, and sanity checks are executed by their shared base. The application classes no longer contain copies of either driver.
Only explicit policy hooks differ. Wild uses absolute AMIPS and stop_energy; SimWild reports
quality normalized by each cell's tag-dependent target and stops at relative quality 1. SimWild
also supplies its existing optional stop_at_float policy and tag-aware refinement, while TetWild
retains its extra open-boundary sanity diagnostic.
The conformance suite now runs one complete iteration under both the batched and interleaved schedules, serially from identical meshes in each dimension: TriWild then homogeneous SimWild-2D, and TetWild then homogeneous SimWild-3D. It compares iteration counts, canonical connectivity, per-cell qualities, floating and exact positions, and checks that SimWild kept its homogeneous tags and derived interface invariant.