Skip to content

Decide a collapse's fate before copying its star - #9587

Open
IasonManolas wants to merge 8 commits into
CGAL:mainfrom
IasonManolas:mr/collapse-decide-before-simulating
Open

Decide a collapse's fate before copying its star#9587
IasonManolas wants to merge 8 commits into
CGAL:mainfrom
IasonManolas:mr/collapse-decide-before-simulating

Conversation

@IasonManolas

@IasonManolas IasonManolas commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

collapse_edge() decides validity partly via CollapseTriangulation, which copies the edge's star into its own triangulation, collapses it there, and inspects the result: expensive, and unconditional. Instrumenting its return value on bear.mesh showed why that's suspicious: of 31,518 candidates reaching it, 14,215 (45%) came back ANGLE_PROBLEM, decided entirely by the copy's last step, a before/after dihedral-angle comparison needing only geometry the real triangulation already has, and no other rejection reason ever fired. Separately, the guards ahead of the copy were never ordered by cost: is_cells_set_manifold walks the star of both edge endpoints where the angle comparison walks it once, the same cheap-test-behind-expensive-test defect this project's prior work found elsewhere in this file. Three commits:

  • reject a collapse on its angles before simulating it: adds collapse_keeps_angles_acceptable(), reproducing CollapseTriangulation's angle comparison so a decisive answer agrees with the copy's. Placed where the copy already sat, so this commit only adds an early rejection.
  • test the angles of a collapse before the manifoldness: reorders the new guard ahead of is_cells_set_manifold (cheaper, more selective).
  • stop simulating a collapse that is already decided: skips the copy when the angle test's own verdict is decisive; still builds it, unchanged, for the minority it cannot decide.

Only collapse_short_edges.h is touched, inside collapse_edge() and the new collapse_keeps_angles_acceptable().

Why the angle test can only sometimes decide on its own

collapse_keeps_angles_acceptable() returns ANGLES_REJECTED, ANGLES_ACCEPTED, or ANGLES_UNDECIDED, not a plain bool:

if (CGAL::abs(after_sq - curr_max_sq) < angle_margin
 || CGAL::abs(after_sq - acceptable_sq) < angle_margin)
{
  undecided = true;
  continue;
}
if (curr_max_cos < after && acceptable_max_cos < after)
  return ANGLES_REJECTED;

Dihedral_angle_cosine stores a cosine as (sign, sq_num, sq_den) and its operator< cross-multiplies in double: l.sq_num * r.sq_den < r.sq_num * l.sq_den. This is not transitive: two (sq_num, sq_den) pairs can represent the same cosine yet compare equal to each other while rounding differently against a third value, if they arose from reducing the same cells in a different order. collapse_keeps_angles_acceptable() reduces its "before" set in flat_set/small_vector order; CollapseTriangulation's copy reduces the same cells in Cell_circulator order. So on a candidate whose worst angle sits within rounding of a threshold, the two can reach opposite verdicts. Because a collapse mutates the mesh, one differing decision propagates through the rest of the run.

Why 1e-9. Measuring this disagreement directly (on a related, margin-less version of this same guard) found it at `1e-9'. Thus a gap below it cannot be trusted, and a gap above it cannot have come from rounding.

Why a margin instead of fixing operator<. The comparator's non-transitivity is a real, pre-existing defect used throughout the package, including flip decisions this MR doesn't touch. Fixing it changes which cell wins a tied maximum everywhere it's compared, including inside CollapseTriangulation's own unmodified code, which is a behavior change against cgal/main, and this MR's evidence is entirely byte-identity. The margin changes nothing except the cases the comparator was never trustworthy for.

The cost. ANGLES_UNDECIDED is measured at 0.3-4.0% of a mesh's rejections. For those, collapse_edge() still builds CollapseTriangulation and asks it, exactly as before, which is why the class isn't deleted here.

Testing

Verdict equivalence, instrumented directly against cgal/main: built plain main and main + commits 1–2 (angle test added and reordered, simulation still runs unconditionally), both counting CollapseTriangulation::collapse()'s return value.

mesh plain cgal/main: simulated / valid / refused + commits 1–2: simulated / valid / refused
bear 31,518 / 17,303 / 14,215 17,303 / 17,303 / 0

The valid count matches exactly.

Byte-identical output, all three commits applied to cgal/main:

mesh tets byte-identical
bear ~90K YES
bunny00 ~260K YES
101556.mesh (Thingi10K) 4,539,866 YES

101556.mesh matters specifically: a margin-less version of this angle test was not byte-identical here (4 of 10 large Thingi10K meshes drifted), which is the reason the margin exists. With it, this mesh is clean too.

Performance. perf stat -e instructions, Linux, Release, sequential. Instructions are the primary metric (deterministic, environment-independent); wall-clock is 8-run position-balanced ABBA where practical.

mesh iterations cgal/main instr this MR Δ instr cgal/main wall this MR wall Δ wall
bear 10 67.07 G 50.43 G −24.81% 21.61 s 18.13 s −16.12%
bunny00 10 148.81 G 123.36 G −17.10% 69.20 s 60.49 s −12.59%
101556.mesh 3 2441.17 G 1776.06 G −27.25% 1160.45 s (single run) 1034.45 s (single run) −10.86% (single run, corroborating only)

Release Management

  • Affected package(s): Tetrahedral_remeshing
  • License and copyright ownership: unchanged

…ing it

CollapseTriangulation copies the star of the edge into a triangulation of
its own and runs the collapse on it, only to reach a last test comparing
the dihedral angles before and after. On bear.mesh that test is what
rejects the collapse in every single rejected case - 14215 of the 31518
candidates that get that far - so nearly half of those copies are built
to answer a question about the geometry alone.

The cells the collapse keeps are the star minus the ring of the edge,
with both extremities at the collapse point, so the comparison can be
made on the triangulation itself, before the copy exists.

Dihedral_angle_cosine's operator< cross-multiplies stored squares in
double, which is not transitive: two representations of the same cosine,
arising from cells reduced in a different order, can compare differently
against a third value. This test and the copy's own version of the same
comparison reduce over the cells in different orders, so a boolean version
of this test would occasionally disagree with the copy on a candidate
within rounding of a threshold - measured at about 1 part in 1e16, i.e.
one ULP. Comparing on the signed square of the cosine (values in [-1, 1])
with a 1e-9 margin - nine orders above that measured disagreement and far
below anything geometrically meaningful - lets the test answer decisively
away from a threshold and defer to the copy, unchanged, whenever it is
within the margin of one. Measured to be within margin on 0.3-4.0% of a
mesh's rejections.

Byte-identical on bear, bear_2sub, bear_8sub, bunny00, and on a
Thingi10K sample of 13 meshes up to 4.5M tets including 101556.mesh
(578,115 v / 4,539,866 tets) - the mesh whose scale first exposed the
comparator's non-transitivity in an earlier, margin-less version of
this same test.
…oldness

Both collapse_keeps_angles_acceptable() and is_cells_set_manifold() are
pure predicates over the same cell set, so their order does not change
the result - only which one pays for a candidate the other would also
have rejected. The angle test walks the star once; the manifoldness test
walks the star of each of the edge's two vertices, so it is the more
expensive of the two, and the angle test is also the more selective one.
Running it first leaves the manifoldness walk unpaid for whatever it
rejects.
…ided

Once collapse_keeps_angles_acceptable() returns ANGLES_ACCEPTED, its verdict
is exactly the one CollapseTriangulation's copy would reach performing the
same comparison on its own copy of the star - that is what ANGLES_ACCEPTED
means. Building the copy, running the collapse on it and re-deriving that
comparison is then answering a question already answered. The copy is still
built for the ANGLES_UNDECIDED minority the angle test could not decide on
its own margin, where it keeps its original role as the tie-breaker.

On bear.mesh the angle test was handed 31518 candidates and rejected 14215
of them outright; with it running first, the copy is built only for the
17303 survivors, of which measurement puts 0.3-4.0% at ANGLES_UNDECIDED
depending on the mesh - the rest proceed straight to the real collapse.

Byte-identical on bear, bear_2sub, bear_8sub, bunny00, and the same
13-mesh Thingi10K sample used to validate the angle test itself, up to
and including 101556.mesh (578,115 v / 4,539,866 tets).
…finite-vertex adjacency

The CollapseTriangulation simulation used to catch every collapse whose
two merged neighbor cells both have the infinite vertex as their
opposite vertex, before the real mutating collapse() ran. Skipping the
simulation on the decisive-accept path removed that guard for the
majority of candidates: collapse() rewires cell neighbor pointers
before its own equivalent check, so a candidate that hits this case
now corrupts the triangulation instead of being rejected cleanly.

collapse_avoids_infinite_adjacency() replicates the same check
read-only on the real triangulation, and is called on exactly the
decisive-accept path that lost the simulation's protection.
if(local_tri.collapse() != VALID)
return Vertex_handle();
}
else if(!collapse_avoids_infinite_adjacency<C3t3>(edge, collapse_type, c3t3))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a new condition, or moved from somewhere else?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the commit message explains, its a read only replication of the check on the real triangulation.
The check in the original code happened around lines 887-888.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think it is possible to avoid the duplication of this test?

Resolving a cell incident to the collapsed edge - finding the two cells
that take its place and this cell's index in each of them - was written
out three times: in the CollapseTriangulation simulation, in collapse()
itself, and in the read-only check added for the decisive-accept fast
path.

Introduce Collapse_star_cell to name that triple, and
make_collapse_star_cell() as its single point of derivation. The
infinite-vertex adjacency test becomes a member of it, so it can no
longer be called with a cell and an index that do not match.

collapse() now resolves the whole star in a first pass and mutates in a
second one, which lets it reject an infinite adjacency itself.
collapse_avoids_infinite_adjacency() then has nothing left to do and is
removed, taking the third traversal of the star with it.

Hoisting the test also fixes the bail-out: it used to run after
set_neighbor() had already rewired part of the star, so returning there
left the triangulation half-collapsed.

Output is byte-identical on bear (13.8k vertices), bunny00 (37.7k) and
101556 (1.2M vertices / 8.3M cells).
@sloriot

sloriot commented Aug 11, 2026

Copy link
Copy Markdown
Member

Successfully tested in CGAL-6.3-Ic-52

<boost/container/flat_set.hpp> is already included two lines below. The second
copy only existed because this branch predates that one on main, and it is the
last thing making this branch conflict with CGAL#9594.
@github-actions github-actions Bot removed the Tested label Aug 12, 2026
@github-actions

Copy link
Copy Markdown

This pull-request was previously marked with the label Tested, but has been modified with new commits. That label has been removed.

Hoisting the infinite-adjacency test into collapse() gave that function a
second outcome: a null vertex handle, meaning the star was refused before
anything had been rewired. The three call sites here did not read it. They
handed it straight to set_dimension(), which dereferenced it.

They had also already moved the endpoints, in the expectation of a collapse
that then did not happen. A null check on its own would leave both vertices
sitting on the midpoint with no collapse performed, which is worse than the
crash because it is silent. So the points are put back, and the single
set_dimension() call now sits behind the guard rather than being repeated in
each of the three branches.

Reaching this needs a star whose two outer neighbours both face the infinite
vertex. None of the CDT meshes the branch was tested on produce one, which is
why it went unseen. Nine mesh_3 generated meshes do: four of them crashed
before this, and all nine now finish with output identical to main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants