git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitd693ab7aa72d046f76dffb9f616b3c9ff2d8aa9d
parent979353aa6a
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 22:27
fix: a remesh keeps a grouped point

A collapse that would leave a corner with fewer than three triangles is
refused: it stranded a pulled point at the tip of a spike, which was
then dropped with its identity, values and groups, and the pull went on
with nothing to pull. The survivor of a collapse is the end in more
groups, and joins the other end's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md     | 24 ++++++++++++++++++++++++
 src/main.rs   | 35 +++++++++++++++++++++++++++++++++++
 src/remesh.rs | 43 ++++++++++++++++++++++++++++++++++++++++---
 3 files changed, 99 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2301793..810d1bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1595,6 +1595,30 @@ is its test, and fails without the rule ("still changing 162 edges after
 20 rounds"). `remesh::last_changes` is the count the test and the profile
 read.
 
+### A grouped point survives a remesh
+
+Two rules in `remesh::collapse_pass` (2026-09-29), found on the project
+above: its pull group lost its one member at frame 16 and the pull went
+on with nothing to pull, which is why the simulation "stopped moving by
+frame 30" — the remeshed point count held at 524 for the rest of the run.
+
+- **A collapse that would strand a corner is refused.** The two
+  triangles on a collapsed edge fold to nothing, and each takes one
+  triangle from its third corner; a corner left with fewer than three
+  has no fan to stand in, and one left with none is a point on no
+  triangle, which `into_detail` drops — identity, values, groups and all.
+  That is how the pulled point went: at the tip of a spike, its
+  neighbours collapsing around it, it was never one end of a collapsed
+  edge itself. (The other rule, `too_long`, is what stops a collapse
+  undoing a split; this one is the link condition remeshers carry.)
+- **The survivor of a collapse is the end in more groups**, the lower
+  index on a tie as it always was, and it joins the other end's groups:
+  a point in a group is a point something downstream names.
+
+`a_grouped_point_survives_a_remesh` pulls a sphere's point out a spike
+over forty remeshes and fails without the first rule; the second is not
+what the fixture exercises and is kept for the case it describes.
+
 ### Simulation checkpoints
 
 A step is not invertible, so going back means going forward from
diff --git a/src/main.rs b/src/main.rs
index 2d458a8..f60cc7d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10869,6 +10869,41 @@ mod tests {
         assert_eq!(asked, 4000);
     }
 
+    /// A point in a group survives a remesh: a pulled point at the tip of
+    /// a spike keeps its identity, its values and its membership while the
+    /// mesh around it is split and collapsed. A collapse that would leave a
+    /// corner with fewer than three triangles is refused — it stranded the
+    /// pulled point, which `into_detail` then dropped with everything it
+    /// was — and where the pulled point is one end of a collapsed edge it
+    /// is the end that survives, joining the other end's groups.
+    #[test]
+    fn a_grouped_point_survives_a_remesh() {
+        let mut d = sphere_detail(Vec3::ZERO, 1.0, 10, 14);
+        let tip = 37usize;
+        let id = d.ids()[tip];
+        d.points_mut().create_group("pull");
+        d.points_mut().add_to_group("pull", tip);
+        d.points_mut().create("mass", crate::detail::AttribValue::Float(0.0));
+        d.points_mut().set_value("mass", tip, crate::detail::AttribValue::Float(7.5)).unwrap();
+        let settings = Settings { target: 0.25, iterations: 3, relax: 0.0, ..Default::default() };
+        let mut outward = d.pos(tip).normalize();
+        for step in 0..40 {
+            // The pull: the tip out along its ray, a little each step, so
+            // the edges around it stretch and the remesh works there.
+            let p = d.points().group_members("pull");
+            assert_eq!(p.len(), 1, "step {step}: the group has one member, not {}", p.len());
+            let at = p[0] as usize;
+            assert_eq!(d.ids()[at], id, "step {step}: the member is the point it was");
+            assert_eq!(d.points().value("mass", at), Some(crate::detail::AttribValue::Float(7.5)), "step {step}: with its values");
+            let pos = d.pos(at);
+            outward = if pos.length() > 1e-3 { pos.normalize() } else { outward };
+            d.set_pos(at, pos + outward * 0.08);
+            d = remesh(&d, settings);
+            assert!(d.is_closed(), "step {step}: still a closed surface");
+        }
+        assert!(d.pos(d.points().group_members("pull")[0] as usize).length() > 3.0, "the tip went out with the pull");
+    }
+
     /// A remesh SETTLES: run again on what it made, it comes to a mesh it
     /// finds nothing to do to, and hands that back as it was given — the
     /// primitives and their order untouched, which is what lets a step of
diff --git a/src/remesh.rs b/src/remesh.rs
index 88a67cc..19bb203 100644
--- a/src/remesh.rs
+++ b/src/remesh.rs
@@ -400,7 +400,8 @@ fn split_pass(m: &mut Mesh, target: f32) -> usize {
 
 /// Collapse every edge shorter than 4/5 of the target.
 ///
-/// The survivor keeps its identity and values; the other endpoint is
+/// The survivor — the end in more groups, else the lower index — keeps its
+/// identity and values and joins the other end's groups; the other end is
 /// tombstoned and every triangle referencing it is rewired. Collapses that
 /// would leave a neighbour edge too long are refused, which is what stops the
 /// pass from undoing the splits that just ran.
@@ -409,10 +410,18 @@ fn collapse_pass(m: &mut Mesh, target: f32) -> usize {
     let long = target * 4.0 / 3.0;
     let mut done = 0;
     for (e, _) in m.edges() {
-        let (a, b) = (e[0], e[1]);
-        if m.dead_point[a as usize] || m.dead_point[b as usize] || m.len_of(e) >= short {
+        if m.dead_point[e[0] as usize] || m.dead_point[e[1] as usize] || m.len_of(e) >= short {
             continue;
         }
+        // Which end survives. The one in more groups: a point in a group is
+        // a point something downstream names — the pull's, a pin's — and
+        // the other end is not. Until 2026-09-29 the lower index always
+        // survived, so a pulled point was collapsed into the neighbour it
+        // had been pulled towards, its identity, its values and its
+        // membership gone with it, and the pull went on with nothing to
+        // pull. The lower index still survives a tie, as it always did.
+        let in_groups = |p: u32| m.groups.iter().filter(|(_, members)| members.get(p as usize).copied().unwrap_or(false)).count();
+        let (a, b) = if in_groups(e[1]) > in_groups(e[0]) { (e[1], e[0]) } else { (e[0], e[1]) };
         // Would the survivor end up with an edge that the next split pass
         // would just cut again? Then leave it: two passes undoing each other
         // is how a remesh oscillates instead of converging.
@@ -441,8 +450,36 @@ fn collapse_pass(m: &mut Mesh, target: f32) -> usize {
         if folds {
             continue;
         }
+        // Refuse a collapse that would strand a corner. The triangles on
+        // the edge fold to nothing, and each takes one triangle from its
+        // third corner: a corner left with fewer than three has no fan
+        // left to stand in — at two it is a fold, at none it is a point on
+        // no triangle, which `into_detail` drops. Until 2026-09-29 a point
+        // could be dropped that way with its identity, values and groups:
+        // a pulled point at the tip of a spike, its neighbours collapsing
+        // around it, went from the pull group with nothing to say so.
+        let strands = m.tris_of(b).iter().any(|&t| {
+            let tri = m.tris[t];
+            if !tri.contains(&a) {
+                return false;
+            }
+            tri.iter().any(|&q| q != a && q != b && m.tris_of(q).len() < 4)
+        });
+        if strands {
+            continue;
+        }
 
         m.dead_point[b as usize] = true;
+        // The survivor stands for both: what the other end was in, it is
+        // in. A group is a set of places named downstream, and a collapse
+        // that dropped one lost what named it.
+        for (_, members) in m.groups.iter_mut() {
+            if members.get(b as usize).copied().unwrap_or(false) {
+                if let Some(slot) = members.get_mut(a as usize) {
+                    *slot = true;
+                }
+            }
+        }
         for t in m.tris_of(b) {
             if m.dead_tri[t] {
                 continue;