Skip to main content

mesh_graph/ops/
collapse.rs

1use glam::Vec3;
2use hashbrown::{HashMap, HashSet};
3use itertools::Itertools;
4use tracing::{error, instrument};
5
6use crate::{
7    Face, FaceId, HalfedgeId, MeshGraph, VertexId, error_none,
8    ops::{EdgeLengthCleanup, PendingEdges, PendingOrder},
9    utils::unwrap_or_return,
10};
11
12impl MeshGraph {
13    /// Collapses edges until all edges have a length above the minimum length.
14    ///
15    /// Returns whether every edge ended up above the threshold, or some remained —
16    /// either because the work bound was reached or because the survivors cannot be
17    /// collapsed without inverting a face. See [`EdgeLengthCleanup`].
18    ///
19    /// This will schedule necessary updates to the BVH but you have to call
20    /// `refit_bvh()` after the operation.
21    #[instrument(skip(self))]
22    pub fn collapse_until_edges_above_min_length(
23        &mut self,
24        min_length_squared: f32,
25        marked_vertices: &mut HashSet<VertexId>,
26    ) -> EdgeLengthCleanup {
27        #[cfg(feature = "instrumentation")]
28        crate::set_current_op("collapse");
29        #[cfg(feature = "instrumentation")]
30        crate::probe_chain_begin(self);
31        let mut halfedges_to_collapse = PendingEdges::new(
32            self.halfedges_map(|len_sqr| len_sqr < min_length_squared),
33            PendingOrder::ShortestFirst,
34        );
35
36        // Edges popped as shortest-pending but rejected by `can_collapse_edge_inner`.
37        // They stay pending, because a later collapse can make them collapsible, but
38        // re-testing them every iteration is almost pure waste: measured over the
39        // production scans, 18466 rejections produced 16 eventual collapses (0.09%).
40        //
41        // So each entry records the tick it was rejected at, and only goes back into the
42        // queue once the geometry its verdict depends on has actually moved. The entry is
43        // `(edge, squared length, tick when rejected)`.
44        let mut deferred: Vec<(HalfedgeId, f32, u32)> = Vec::new();
45
46        // `can_collapse_edge_inner` rejects an edge when collapsing it would invert a
47        // face, which it decides from the one-rings of the edge's two endpoints. A
48        // collapse moves exactly one vertex, so a rejected edge `(A, B)` can only become
49        // collapsible if the moved vertex is `A`, `B`, or a neighbour of either -
50        // equivalently, if `A` or `B` lies in the moved vertex's closed one-ring.
51        // Recording when each vertex last moved therefore says exactly which rejected
52        // edges are worth another look.
53        let mut vertex_moved_at: HashMap<VertexId, u32> = HashMap::new();
54        let mut tick: u32 = 0;
55
56        // Bound the work by the initial problem size, not the mesh size: a degenerate
57        // region (e.g. a cluster of zero-length edges after a bad merge) can keep
58        // re-feeding the set, which made this loop burn up to the whole halfedge count
59        // while growing the mesh. Healthy runs drain at ~1.2x the initial set size.
60        let budget = halfedges_to_collapse.len() * 2 + 100;
61
62        for _ in 0..budget {
63            if halfedges_to_collapse.is_empty() {
64                break;
65            }
66
67            // Hand back the parked candidates whose endpoints have moved since they were
68            // rejected. The rest stay parked, costing two integer lookups instead of a
69            // pair of one-ring walks each.
70            for (he_id, len, rejected_at) in std::mem::take(&mut deferred) {
71                let Some(he) = self.halfedges.get(he_id) else {
72                    // The edge is gone, so it is not pending any more either.
73                    halfedges_to_collapse.remove(&he_id);
74                    continue;
75                };
76
77                // If the endpoints cannot be resolved the edge is broken; retry it so the
78                // usual rejection path reports it rather than parking it forever.
79                let moved_since = match he.start_vertex(self) {
80                    Some(start) => {
81                        let moved = |v| vertex_moved_at.get(&v).copied().unwrap_or(0) > rejected_at;
82                        moved(start) || moved(he.end_vertex)
83                    }
84                    None => true,
85                };
86
87                if moved_since {
88                    halfedges_to_collapse.requeue(he_id, len);
89                } else {
90                    deferred.push((he_id, len, rejected_at));
91                }
92            }
93
94            // Take the shortest pending edge that can actually be collapsed. Rejected
95            // candidates are held in `deferred` so they are neither lost nor retried
96            // within this iteration, which reproduces the previous linear scan's
97            // "minimum among collapsible edges" choice.
98            let mut found = None;
99
100            while let Some((he_id, len)) = halfedges_to_collapse.pop_live() {
101                // Note: this mutates the mesh even when it returns `None` - it reseeds
102                // `outgoing_halfedge` on both endpoints to anchor the one-ring walk in
103                // `check_inverted_faces`. Each call seeds its own endpoints before its
104                // own walk, so the verdict does not depend on which candidates ran
105                // before it.
106                if let Some((twin_id, start_v_id, end_v_id, center)) =
107                    self.can_collapse_edge_inner(he_id)
108                {
109                    found = Some((he_id, twin_id, start_v_id, end_v_id, center));
110                    break;
111                }
112
113                deferred.push((he_id, len, tick));
114            }
115
116            let Some((min_he_id, min_twin_id, min_start_v_id, min_end_v_id, min_center)) = found
117            else {
118                // Couldn't find a valid halfedge to collapse. Everything still pending
119                // was rejected this iteration, so no further progress is possible.
120                //
121                // `deferred` may hold *more* entries than the map holds keys, because
122                // `pop_live` can yield the same id twice (see its docs). That surplus is
123                // benign. A shortfall is not: it means a pending entry had no heap entry
124                // pointing at it, i.e. a push site is missing and that edge is lost for
125                // the rest of the run. Only the latter is asserted.
126                debug_assert!(
127                    deferred.len() >= halfedges_to_collapse.len(),
128                    "heap drained with {} pending and only {} deferred - a push site is missing",
129                    halfedges_to_collapse.len(),
130                    deferred.len()
131                );
132                break;
133            };
134
135            tick += 1;
136
137            let start_vertex_id = unwrap_or_return!(
138                // checked in `can_collapse_edge_inner`
139                self.halfedges[min_he_id].start_vertex(self),
140                "Start vertex not found",
141                EdgeLengthCleanup::Stalled
142            );
143
144            let collapse_edge_result = self.collapse_edge_inner(
145                min_he_id,
146                min_twin_id,
147                min_start_v_id,
148                min_end_v_id,
149                min_center,
150            );
151
152            let vertex_neighborhoods_to_check = if collapse_edge_result.added_vertices.is_empty() {
153                if collapse_edge_result.removed_halfedges.is_empty() {
154                    vec![]
155                } else {
156                    vec![start_vertex_id]
157                }
158            } else {
159                marked_vertices.extend(collapse_edge_result.added_vertices.iter().copied());
160
161                let mut neighborhood = collapse_edge_result.added_vertices;
162                neighborhood.push(start_vertex_id);
163
164                neighborhood
165            };
166
167            halfedges_to_collapse.remove(&min_he_id);
168
169            for removed_he_id in collapse_edge_result.removed_halfedges {
170                halfedges_to_collapse.remove(&removed_he_id);
171            }
172
173            let mut halfedges_to_check = HashSet::new();
174
175            for vertex_id in vertex_neighborhoods_to_check {
176                let Some(outgoing_halfedges) = self.outgoing_halfedges.get(vertex_id) else {
177                    // the vertex might have been removed by a previous cleanup
178                    continue;
179                };
180
181                // This vertex moved, or was created, by the collapse.
182                vertex_moved_at.insert(vertex_id, tick);
183
184                for &halfedge_id in outgoing_halfedges {
185                    let Some(halfedge) = self.halfedges.get(halfedge_id) else {
186                        error!("Halfedge not found");
187                        continue;
188                    };
189
190                    // ... and so did its one-ring, as far as the inversion guard's
191                    // verdict on edges incident to those neighbours is concerned.
192                    vertex_moved_at.insert(halfedge.end_vertex, tick);
193
194                    let twin_id = unwrap_or_return!(
195                        halfedge.twin,
196                        "Twin not found",
197                        EdgeLengthCleanup::Stalled
198                    );
199
200                    halfedges_to_check.insert(halfedge_id.min(twin_id));
201
202                    if let Some(face_id) = halfedge.face {
203                        if let Some(face) = self.faces.get(face_id) {
204                            self.bvh
205                                .insert_or_update_partially(face.aabb(self), face.index, 0.0);
206                        } else {
207                            error!("Face not found. BVH will not be updated.");
208                        }
209                    }
210                }
211            }
212
213            for he_id in halfedges_to_check {
214                let Some(he) = self.halfedges.get(he_id) else {
215                    // The pair `min` inserted this id: either the halfedge itself or
216                    // its twin was dead when the pair was collected (the twin is not
217                    // liveness-checked at the `min` site). Skip and report once so the
218                    // collision of stale SlotMap keys can be diagnosed instead of
219                    // aborting the run.
220                    #[cfg(feature = "instrumentation")]
221                    crate::report_dead_halfedge_in_collapse_check(self, he_id);
222                    continue;
223                };
224
225                let len_sqr = he.length_squared(self);
226
227                if len_sqr < min_length_squared {
228                    halfedges_to_collapse.insert(he_id, len_sqr);
229                } else {
230                    halfedges_to_collapse.remove(&he_id);
231                }
232            }
233        }
234
235        self.rebuild_outgoing_halfedges();
236
237        // Collapsing absorbs one endpoint into the other, so ids the caller marked can
238        // name vertices that no longer exist. The set is extended with the vertices
239        // cleanup creates, so it has to be pruned of the ones it destroys as well, or
240        // callers are handed dead keys.
241        marked_vertices.retain(|v_id| self.vertices.contains_key(*v_id));
242
243        #[cfg(feature = "instrumentation")]
244        if self.probe_chain_integrity("collapse_until_edges_above_min_length") {
245            crate::state_history_push(self, "collapse_until_edges_above_min_length");
246        }
247
248        #[cfg(feature = "rerun")]
249        self.log_rerun();
250
251        // The loop only exits early once the pending set drains, so anything left in it
252        // is an edge below the threshold that could not be collapsed.
253        if halfedges_to_collapse.is_empty() {
254            EdgeLengthCleanup::Converged
255        } else {
256            EdgeLengthCleanup::Stalled
257        }
258    }
259
260    #[inline]
261    pub fn can_collapse_edge(&mut self, halfedge_id: HalfedgeId) -> bool {
262        self.can_collapse_edge_inner(halfedge_id).is_some()
263    }
264
265    #[instrument(skip(self))]
266    pub fn can_collapse_edge_inner(
267        &mut self,
268        halfedge_id: HalfedgeId,
269    ) -> Option<(HalfedgeId, VertexId, VertexId, Vec3)> {
270        // TODO : consider boundary edge
271        //
272        //          end_vertex
273        //  .            .            .
274        // ( ) ◀─────── ( ) ───────▶ ( )
275        //  '      3     '     2      '
276        //            ╱ ▲ │ ╲
277        //          4╱  │ │  ╲1
278        //          ╱   │ │0  ╲
279        //         ╱    │ │    ╲
280        //        ▼     │ │     ▼
281        //       .      │ │      .
282        //      ( )   he│ │twin ( )
283        //       '      │ │      '
284        //        ▲     │ │     ▲
285        //         ╲    │ │    ╱
286        //          ╲  0│ │   ╱
287        //          1╲  │ │  ╱4
288        //            ╲ │ ▼ ╱
289        //  .      2     .     3      .
290        // ( ) ◀─────── ( ) ───────▶ ( )
291        //  '            '            '
292        //         start_vertex
293
294        let he = self
295            .halfedges
296            .get(halfedge_id)
297            .or_else(error_none!("Halfedge not found"))?;
298        let twin_id = he.twin.or_else(error_none!("Twin halfedge not found"))?;
299        let twin = self
300            .halfedges
301            .get(twin_id)
302            .or_else(error_none!("Twin halfedge not found"))?;
303
304        let start_vertex_id = twin.end_vertex;
305        self.vertices
306            .get_mut(start_vertex_id)
307            .or_else(error_none!("Start vertex not found"))?
308            .outgoing_halfedge = Some(halfedge_id);
309
310        let end_vertex_id = he.end_vertex;
311        self.vertices
312            .get_mut(end_vertex_id)
313            .or_else(error_none!("End vertex not found"))?
314            .outgoing_halfedge = Some(twin_id);
315
316        let start_pos = self
317            .positions
318            .get(start_vertex_id)
319            .or_else(error_none!("Start position not found"))?;
320
321        let end_pos = self
322            .positions
323            .get(end_vertex_id)
324            .or_else(error_none!("End position not found"))?;
325
326        let center = (start_pos + end_pos) * 0.5;
327
328        self.check_inverted_faces(start_vertex_id, center)?;
329        self.check_inverted_faces(end_vertex_id, center)?;
330
331        Some((twin_id, start_vertex_id, end_vertex_id, center))
332    }
333
334    fn check_inverted_faces(&self, vertex_id: VertexId, center: Vec3) -> Option<()> {
335        // just made sure that this exists
336        let face_ids = self.vertices[vertex_id].faces(self).skip(2).collect_vec();
337
338        for face_id in face_ids {
339            let mut orig_positions = Vec::with_capacity(3);
340            let mut new_positions = Vec::with_capacity(3);
341
342            let face = self
343                .faces
344                .get(face_id)
345                .or_else(error_none!("Face not found"))?;
346
347            for v_id in face.vertices(self) {
348                let pos = *self
349                    .positions
350                    .get(v_id)
351                    .or_else(error_none!("Vertex pos not found"))?;
352
353                if v_id == vertex_id {
354                    new_positions.push(center);
355                } else {
356                    new_positions.push(pos);
357                }
358                orig_positions.push(pos);
359            }
360
361            let Some(orig_normal) = Face::normal_from_positions(&orig_positions) else {
362                continue;
363            };
364            let Some(new_normal) = Face::normal_from_positions(&new_positions) else {
365                continue;
366            };
367
368            if orig_normal.dot(new_normal) < 0.0 {
369                return None;
370            }
371        }
372
373        Some(())
374    }
375
376    #[instrument(skip(self))]
377    pub fn collapse_edge_inner(
378        &mut self,
379        halfedge_id: HalfedgeId,
380        twin_id: HalfedgeId,
381        start_v_id: VertexId,
382        end_v_id: VertexId,
383        center_pos: Vec3,
384    ) -> CollapseEdge {
385        let mut result = CollapseEdge::default();
386
387        if start_v_id == end_v_id {
388            error!("Cannot collapse edge between the same vertex");
389            return result;
390        }
391
392        // #[cfg(feature = "rerun")]
393        // {
394        //     self.log_he_rerun("collapse/he", halfedge_id);
395        // }
396        // TODO : consider border vertices
397
398        let he = *unwrap_or_return!(
399            self.halfedges.get(halfedge_id),
400            "Halfedge not found",
401            result
402        );
403        let twin = *unwrap_or_return!(
404            self.halfedges.get(twin_id),
405            "Twin halfedge not found",
406            result
407        );
408
409        if !he.is_boundary() {
410            let (face_id, halfedge_ids) = unwrap_or_return!(
411                self.remove_halfedge_face(halfedge_id),
412                "Could not remove face",
413                result
414            );
415
416            result.removed_faces.push(face_id);
417            result.removed_halfedges.extend(halfedge_ids);
418        }
419        result.removed_halfedges.push(halfedge_id);
420
421        self.remove_outgoing_halfedge(start_v_id, halfedge_id);
422
423        if !twin.is_boundary() {
424            let twin_face_removal = self.remove_halfedge_face(twin_id);
425            #[cfg(feature = "instrumentation")]
426            crate::record_op_trace!(
427                "collapse_edge_inner({halfedge_id:?}): twin-side face removal {twin_face_removal:?}"
428            );
429            if twin_face_removal.is_none() {
430                // The twin-side dismantling failed — typically because the twin was
431                // already removed by the start-side dismantling (a degenerate fold
432                // whose start-side face chain contains the collapsed edge's own
433                // twin). Aborting right away would strand `halfedge_id` with a face
434                // pointer to the already-removed start-side face (the layer-4 ghost:
435                // a live halfedge claiming a removed face). Heal the survivor first:
436                // detach it from the dead face and re-pair it with a fresh boundary
437                // half so no invariant is violated when the op terminates.
438                if let Some(he_mut) = self.halfedges.get_mut(halfedge_id) {
439                    he_mut.face = None;
440                    he_mut.next = None;
441                }
442                self.pair_with_fresh_boundary_half(halfedge_id, start_v_id);
443                return result;
444            }
445            let (face_id, halfedge_ids) =
446                unwrap_or_return!(twin_face_removal, "Failed to remove halfedge face", result);
447
448            result.removed_faces.push(face_id);
449            result.removed_halfedges.extend(halfedge_ids);
450        }
451        result.removed_halfedges.push(twin_id);
452
453        self.remove_outgoing_halfedge(end_v_id, twin_id);
454
455        // Remove the collapsed edge's own halfedges now. Their twins are each
456        // other, so both partners go in the same batch: nothing survives with a
457        // reference to them, no re-pairing needed.
458        #[cfg(feature = "instrumentation")]
459        self.probe_live_face_removal(&[halfedge_id, twin_id], "collapse_own");
460        self.halfedges.remove(halfedge_id);
461        self.halfedges.remove(twin_id);
462
463        // The end vertex is absorbed into the start vertex. Every surviving halfedge that
464        // still points at the end vertex must be re-pointed at the start vertex, and the end
465        // vertex's outgoing halfedges move onto the start vertex's list. `remove_halfedge_face`
466        // keeps the outgoing lists (including the re-paired twins) in sync, so the end
467        // vertex's list is complete here.
468        let end_outgoing = self
469            .outgoing_halfedges
470            .get(end_v_id)
471            .cloned()
472            .unwrap_or_default();
473        for &out_he_id in &end_outgoing {
474            let Some(out_he) = self.halfedges.get(out_he_id) else {
475                continue;
476            };
477            let Some(twin_id) = out_he.twin else {
478                continue;
479            };
480            if let Some(twin) = self.halfedges.get_mut(twin_id) {
481                twin.end_vertex = start_v_id;
482            }
483        }
484        self.outgoing_halfedges
485            .entry(start_v_id)
486            .unwrap() // key exists because we access positions[start_v_id] below
487            .or_default()
488            .extend(end_outgoing);
489
490        self.remove_only_vertex(end_v_id);
491        result.removed_vertices.push(end_v_id);
492
493        // key exists. we accessed it above
494        self.positions[start_v_id] = center_pos;
495
496        // Pick a surviving outgoing halfedge of the start vertex as its seed pointer.
497        let new_outgoing_he_id = self.outgoing_halfedges.get(start_v_id).and_then(|l| {
498            l.iter()
499                .copied()
500                .find(|he_id| self.halfedges.contains_key(*he_id))
501        });
502
503        if let Some(new_outgoing_he_id) = new_outgoing_he_id {
504            // key exists. we accessed it above
505            self.vertices[start_v_id].outgoing_halfedge = Some(new_outgoing_he_id);
506
507            // #[cfg(feature = "rerun")]
508            // {
509            //     self.log_he_rerun(
510            //         "collapse/outgoing",
511            //         self.vertices[start_v_id].outgoing_halfedge.unwrap(),
512            //     );
513            // }
514
515            let cleanup = self.make_vertex_neighborhood_manifold(start_v_id);
516
517            result.added_vertices = cleanup.added_vertices;
518            result.removed_vertices.extend(cleanup.removed_vertices);
519            result.removed_halfedges.extend(cleanup.removed_halfedges);
520            result.removed_faces.extend(cleanup.removed_faces);
521        } else {
522            self.remove_only_vertex(start_v_id);
523
524            result.removed_vertices.push(start_v_id);
525        }
526
527        result
528    }
529
530    /// Collapse an edge in the mesh graph.
531    ///
532    /// This moves the start vertex of the edge to the center of the edge
533    /// and removes the end vertex and the adjacent and opposite faces.
534    ///
535    /// It also performs a cleanup afterwards to remove flaps (faces that share the same vertices).
536    ///
537    /// Returns the vertices, halfedges and faces that were removed.
538    #[instrument(skip(self))]
539    pub fn collapse_edge(&mut self, halfedge_id: HalfedgeId) -> CollapseEdge {
540        let he = *unwrap_or_return!(
541            self.halfedges.get(halfedge_id),
542            "Halfedge not found",
543            CollapseEdge::default()
544        );
545        let twin_id = unwrap_or_return!(he.twin, "Twin missing", CollapseEdge::default());
546        let twin = unwrap_or_return!(
547            self.halfedges.get(twin_id),
548            "Halfedge not found",
549            CollapseEdge::default()
550        );
551
552        let start_v_id = twin.end_vertex;
553        let end_v_id = he.end_vertex;
554
555        let start_pos = *unwrap_or_return!(
556            self.positions.get(start_v_id),
557            "Start position not found",
558            CollapseEdge::default()
559        );
560        let end_pos = *unwrap_or_return!(
561            self.positions.get(end_v_id),
562            "End position not found",
563            CollapseEdge::default()
564        );
565
566        let center_pos = (start_pos + end_pos) * 0.5;
567
568        self.collapse_edge_inner(halfedge_id, twin_id, start_v_id, end_v_id, center_pos)
569    }
570
571    /// Remove a halfedge face and re-connecting the adjacent halfedges.
572    /// Only works on manifold triangle meshes.
573    #[instrument(skip(self))]
574    fn remove_halfedge_face(
575        &mut self,
576        halfedge_id: HalfedgeId,
577    ) -> Option<(FaceId, [HalfedgeId; 2])> {
578        let he = self
579            .halfedges
580            .get(halfedge_id)
581            .or_else(error_none!("Halfedge not found"))?;
582
583        let face_id = he.face.or_else(error_none!("Face not found"))?;
584
585        let next_he_id = he.next.or_else(error_none!("Next halfedge is None"))?;
586        let prev_he_id = he
587            .prev(self)
588            .or_else(error_none!("Previous halfedge is None"))?;
589
590        let next_twin_id = self
591            .halfedges
592            .get(next_he_id)
593            .or_else(error_none!("Next halfedge not found"))?
594            .twin
595            .or_else(error_none!("Next twin halfedge not found"))?;
596        let prev_twin_id = self
597            .halfedges
598            .get(prev_he_id)
599            .or_else(error_none!("Previous halfedge not found"))?
600            .twin
601            .or_else(error_none!("Previous twin halfedge not found"))?;
602
603        let next_he = self
604            .halfedges
605            .get(next_he_id)
606            .or_else(error_none!("Next halfedge not found"))?;
607        let prev_he = self
608            .halfedges
609            .get(prev_he_id)
610            .or_else(error_none!("Previous halfedge not found"))?;
611
612        let next_end_v_id = next_he.end_vertex;
613        let prev_end_v_id = prev_he.end_vertex;
614
615        // A pre-existing asymmetric twin
616        // pair (e.g. from a flap in a degenerate neighborhood) can make the face-derived
617        // vertices above disagree with the derived starts; removing from the face-derived
618        // vertex then leaves a stale entry behind.
619        let next_he_derived_start = next_he
620            .start_vertex(self)
621            .or_else(error_none!("Next halfedge start vertex not found"))?;
622        let prev_he_derived_start = prev_he
623            .start_vertex(self)
624            .or_else(error_none!("Previous halfedge start vertex not found"))?;
625
626        // `prev_twin`'s and `next_twin`'s derived starts before the re-linking below:
627        // `twin(he).end`. In the symmetric case these are `prev_he.end_vertex` and
628        // `next_he.end_vertex`; with an asymmetric twin pair (or a flap where both faces of
629        // the collapsed edge share all three vertices) they can differ, so derive them from
630        // the topology rather than the face. Must be computed before `next_he`/`prev_he` are
631        // deleted below: they are the twins whose ends define these starts.
632        let prev_twin_old_start = self
633            .halfedges
634            .get(prev_twin_id)
635            .and_then(|he| he.twin)
636            .and_then(|twin_id| self.halfedges.get(twin_id))
637            .or_else(error_none!("Previous twin start vertex not found"))?
638            .end_vertex;
639        let next_twin_old_start = self
640            .halfedges
641            .get(next_twin_id)
642            .and_then(|he| he.twin)
643            .and_then(|twin_id| self.halfedges.get(twin_id))
644            .or_else(error_none!("Next twin start vertex not found"))?
645            .end_vertex;
646
647        self.vertices
648            .get_mut(next_end_v_id)
649            .or_else(error_none!("Next end vertex not found"))?
650            .outgoing_halfedge = next_he.next.or_else(error_none!("Next next is None"));
651        self.vertices
652            .get_mut(prev_end_v_id)
653            .or_else(error_none!("Previous end vertex not found"))?
654            .outgoing_halfedge = prev_he.next.or_else(error_none!("Previous next is None"));
655
656        let prev_start_v_id = prev_he
657            .start_vertex(self)
658            .or_else(error_none!("Previous start vertex ID not found"))?;
659        let prev_start_v = self
660            .vertices
661            .get(prev_start_v_id)
662            .or_else(error_none!("Previous start vertex not found"))?;
663
664        if prev_start_v.outgoing_halfedge == Some(prev_he_id) {
665            // checked just above
666            self.vertices[prev_start_v_id].outgoing_halfedge = prev_he
667                .ccw_rotated_neighbour(self)
668                .or_else(|| prev_he.cw_rotated_neighbour(self))
669                .or_else(error_none!(
670                    "Previous start vertex new outgoing halfedge not found"
671                ));
672        }
673
674        self.bvh.remove(
675            self.faces
676                .get(face_id)
677                .or_else(error_none!("Face not found"))?
678                .index,
679        );
680
681        #[cfg(feature = "instrumentation")]
682        self.probe_live_face_removal(&[next_he_id, prev_he_id], "remove_halfedge_face");
683        self.halfedges.remove(next_he_id);
684        self.halfedges.remove(prev_he_id);
685        self.remove_outgoing_halfedge(next_he_derived_start, next_he_id);
686        self.remove_outgoing_halfedge(prev_he_derived_start, prev_he_id);
687
688        if let Some(face) = self.faces.remove(face_id) {
689            self.bvh.remove(face.index);
690        }
691        #[cfg(feature = "instrumentation")]
692        crate::record_face_death(face_id);
693
694        if self.halfedges.contains_key(next_twin_id) && self.halfedges.contains_key(prev_twin_id) {
695            self.halfedges.get_mut(next_twin_id).unwrap().twin = Some(prev_twin_id);
696            self.halfedges.get_mut(prev_twin_id).unwrap().twin = Some(next_twin_id);
697
698            // Re-linking the twins above changes the *derived* start vertex of `prev_twin`
699            // (`start_vertex == twin.end_vertex`): it used to start at `prev_he.end_vertex` and
700            // now starts at `next_twin.end_vertex`. Keep `outgoing_halfedges` consistent by moving
701            // `prev_twin` between the two lists.
702            let prev_twin_new_start = self
703                .halfedges
704                .get(next_twin_id)
705                .or_else(error_none!("Next twin halfedge not found"))?
706                .end_vertex;
707            self.remove_outgoing_halfedge(prev_twin_old_start, prev_twin_id);
708            if let Some(list) = self.outgoing_halfedges.get_mut(prev_twin_new_start) {
709                list.push(prev_twin_id);
710            }
711
712            // The re-link above may also change `next_twin`'s derived start (in a flap, where
713            // `prev_twin.end_vertex` differs from `next_he.end_vertex`). Keep its list entry in
714            // sync like `prev_twin`'s: `next_twin`'s new derived start is `prev_twin.end_vertex`.
715            let next_twin_new_start = self
716                .halfedges
717                .get(prev_twin_id)
718                .or_else(error_none!("Previous twin halfedge not found"))?
719                .end_vertex;
720            if next_twin_old_start != next_twin_new_start {
721                self.remove_outgoing_halfedge(next_twin_old_start, next_twin_id);
722                if let Some(list) = self.outgoing_halfedges.get_mut(next_twin_new_start) {
723                    list.push(next_twin_id);
724                }
725            }
726        } else {
727            // One or both twins are gone (degenerate neighborhood where a twin was
728            // another halfedge of the same removed face, or a self-twinned member).
729            // Whichever twin partner survives must not be left twinless: re-pair it
730            // with a fresh boundary half so the invariant (every halfedge has a
731            // twin) holds when the op terminates.
732            if self.halfedges.contains_key(next_twin_id)
733                && self
734                    .pair_with_fresh_boundary_half(next_twin_id, next_end_v_id)
735                    .is_none()
736            {
737                error!("remove_halfedge_face: could not re-pair next twin {next_twin_id:?}");
738            }
739            if self.halfedges.contains_key(prev_twin_id)
740                && prev_twin_id != next_twin_id
741                && self
742                    .pair_with_fresh_boundary_half(prev_twin_id, prev_end_v_id)
743                    .is_none()
744            {
745                error!("remove_halfedge_face: could not re-pair prev twin {prev_twin_id:?}");
746            }
747        }
748
749        Some((face_id, [next_he_id, prev_he_id]))
750    }
751}
752
753#[derive(Default, Debug)]
754pub struct CollapseEdge {
755    pub removed_vertices: Vec<VertexId>,
756    pub removed_halfedges: Vec<HalfedgeId>,
757    pub removed_faces: Vec<FaceId>,
758
759    pub added_vertices: Vec<VertexId>,
760}
761
762#[cfg(test)]
763mod test {
764    use super::*;
765    use crate::ops::EdgeLengthCleanup;
766    use crate::utils::{build_grid, mesh_invariant_violations};
767
768    #[test]
769    #[allow(unused_variables)]
770    fn test_collapse_edge() {
771        let mut mesh_graph = MeshGraph::new();
772
773        let face1 = mesh_graph
774            .add_face_from_positions(
775                Vec3::new(0.0, 0.0, 0.0),
776                Vec3::new(0.0, 4.0, 0.0),
777                Vec3::new(1.0, 2.0, 0.0),
778            )
779            .face_id;
780
781        let he1 = mesh_graph.faces[face1]
782            .halfedges(&mesh_graph)
783            .collect::<Vec<_>>()[1];
784
785        let face2 = mesh_graph
786            .add_face_from_halfedge_and_position(he1, Vec3::new(2.0, 4.0, 0.0))
787            .unwrap()
788            .face_id;
789
790        let he2 = mesh_graph.faces[face2]
791            .halfedges(&mesh_graph)
792            .collect::<Vec<_>>()[2];
793
794        let face3 = mesh_graph
795            .add_face_from_halfedge_and_position(he2, Vec3::new(3.0, 2.0, 0.0))
796            .unwrap()
797            .face_id;
798
799        let he3 = mesh_graph.faces[face3]
800            .halfedges(&mesh_graph)
801            .collect::<Vec<_>>()[1];
802
803        let face4 = mesh_graph
804            .add_face_from_halfedge_and_position(he3, Vec3::new(4.0, 4.0, 0.0))
805            .unwrap()
806            .face_id;
807
808        let he4 = mesh_graph.faces[face4]
809            .halfedges(&mesh_graph)
810            .nth(2)
811            .unwrap();
812
813        let face5 = mesh_graph
814            .add_face_from_halfedge_and_position(he4, Vec3::new(4.0, 0.0, 0.0))
815            .unwrap()
816            .face_id;
817
818        let he5 = mesh_graph.faces[face5]
819            .halfedges(&mesh_graph)
820            .nth(2)
821            .unwrap();
822
823        let face6 = mesh_graph
824            .add_face_from_halfedge_and_position(he5, Vec3::new(2.0, 0.0, 0.0))
825            .unwrap()
826            .face_id;
827
828        let he6 = mesh_graph.faces[face6]
829            .halfedges(&mesh_graph)
830            .nth(2)
831            .unwrap();
832
833        let he3 = mesh_graph.faces[face3]
834            .halfedges(&mesh_graph)
835            .nth(2)
836            .unwrap();
837
838        let face7 = mesh_graph
839            .add_face_from_halfedges(he6, he3)
840            .unwrap()
841            .face_id;
842
843        let he7 = mesh_graph.faces[face7]
844            .halfedges(&mesh_graph)
845            .next()
846            .unwrap();
847
848        let he1 = mesh_graph.faces[face1]
849            .halfedges(&mesh_graph)
850            .nth(2)
851            .unwrap();
852
853        mesh_graph.add_face_from_halfedges(he1, he7).unwrap();
854
855        assert_eq!(mesh_graph.vertices.len(), 8);
856        assert_eq!(mesh_graph.halfedges.len(), 30);
857        assert_eq!(mesh_graph.faces.len(), 8);
858
859        #[cfg(feature = "rerun")]
860        mesh_graph.log_rerun();
861
862        let edge_to_collapse = mesh_graph.faces[face3]
863            .halfedges(&mesh_graph)
864            .nth(2)
865            .unwrap();
866
867        let start_v_id = mesh_graph.halfedges[edge_to_collapse]
868            .start_vertex(&mesh_graph)
869            .unwrap();
870
871        assert_eq!(mesh_graph.outgoing_halfedges[start_v_id].len(), 5);
872
873        let CollapseEdge {
874            removed_vertices,
875            removed_halfedges,
876            removed_faces,
877            added_vertices,
878        } = mesh_graph.collapse_edge(edge_to_collapse);
879
880        #[cfg(feature = "rerun")]
881        {
882            mesh_graph.log_rerun();
883            crate::RR.flush_blocking().unwrap();
884        }
885
886        assert_eq!(removed_vertices.len(), 1);
887        assert_eq!(removed_halfedges.len(), 6);
888        assert_eq!(removed_faces.len(), 2);
889
890        assert_eq!(mesh_graph.vertices.len(), 7);
891        assert_eq!(mesh_graph.halfedges.len(), 24);
892        assert_eq!(mesh_graph.faces.len(), 6);
893
894        assert_eq!(mesh_graph.outgoing_halfedges[start_v_id].len(), 6);
895    }
896
897    /// An edge the inversion guard permanently refuses leaves the mesh dirty, and the
898    /// op must say so rather than letting the caller assume it finished.
899    #[test]
900    fn test_collapse_reports_stalled_when_an_edge_cannot_collapse() {
901        const MIN_LEN_SQR: f32 = 0.2;
902
903        let mut mg = build_grid(4);
904
905        let vertex_at = |mg: &MeshGraph, x: f32, y: f32| -> VertexId {
906            mg.positions
907                .iter()
908                .find(|(_, p)| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6)
909                .map(|(v, _)| v)
910                .expect("grid has no vertex at that position")
911        };
912
913        let v = vertex_at(&mg, 2.0, 2.0);
914        let w = vertex_at(&mg, 1.0, 2.0);
915        mg.positions[v] = Vec3::new(2.5, 2.95, 0.0);
916        mg.positions[w] = Vec3::new(2.5, 3.1, 0.0);
917        mg.compute_vertex_normals();
918
919        let outcome = mg.collapse_until_edges_above_min_length(MIN_LEN_SQR, &mut HashSet::new());
920
921        assert_eq!(outcome, EdgeLengthCleanup::Stalled);
922        assert!(mesh_invariant_violations(&mg).is_empty());
923    }
924
925    #[test]
926    fn test_collapse_reports_converged_when_it_drains() {
927        let mut mg = build_grid(6);
928
929        let outcome = mg.collapse_until_edges_above_min_length(2.0, &mut HashSet::new());
930
931        assert_eq!(outcome, EdgeLengthCleanup::Converged);
932        assert_eq!(
933            mg.halfedges
934                .values()
935                .filter(|he| he.length_squared(&mg) < 2.0)
936                .count(),
937            0
938        );
939    }
940
941    /// A mesh with nothing below the threshold converges without touching anything.
942    #[test]
943    fn test_collapse_reports_converged_on_a_clean_mesh() {
944        let mut mg = build_grid(3);
945        let faces = mg.faces.len();
946
947        let outcome = mg.collapse_until_edges_above_min_length(0.01, &mut HashSet::new());
948
949        assert_eq!(outcome, EdgeLengthCleanup::Converged);
950        assert_eq!(mg.faces.len(), faces);
951    }
952
953    #[test]
954    fn test_collapse_until_min_length_leaves_no_dangling_halfedges() {
955        let mut mg = build_grid(8);
956        assert!(
957            mesh_invariant_violations(&mg).is_empty(),
958            "initial mesh must satisfy invariants"
959        );
960
961        let mut marked = HashSet::new();
962        mg.collapse_until_edges_above_min_length(2.0, &mut marked);
963
964        let problems = mesh_invariant_violations(&mg);
965        assert!(
966            problems.is_empty(),
967            "collapse left {} dangling/inconsistent halfedges, e.g.:\n{}\n",
968            problems.len(),
969            problems.iter().take(10).join("\n")
970        );
971    }
972
973    /// The above asserts only that the mesh stayed well formed, which a collapse
974    /// that picked the wrong edges — or no edges — would also satisfy. These pin
975    /// that work actually happened and moved in the right direction.
976    #[test]
977    fn test_collapse_until_min_length_removes_short_edges() {
978        let mut mg = build_grid(6);
979
980        let below_before = mg
981            .halfedges
982            .values()
983            .filter(|he| he.length_squared(&mg) < 2.0)
984            .count();
985        let faces_before = mg.faces.len();
986        assert!(below_before > 0, "fixture has no edges below the threshold");
987
988        mg.collapse_until_edges_above_min_length(2.0, &mut HashSet::new());
989
990        assert!(mesh_invariant_violations(&mg).is_empty());
991
992        let below_after = mg
993            .halfedges
994            .values()
995            .filter(|he| he.length_squared(&mg) < 2.0)
996            .count();
997
998        assert!(
999            below_after < below_before,
1000            "collapse made no progress: {below_before} -> {below_after} short edges"
1001        );
1002        assert!(
1003            mg.faces.len() < faces_before,
1004            "collapsing edges must remove faces: {faces_before} -> {}",
1005            mg.faces.len()
1006        );
1007    }
1008
1009    /// Aggressive collapse on non-planar geometry stays well formed.
1010    ///
1011    /// Note on what this does *not* cover: the deferred-requeue path, which holds
1012    /// candidates that `can_collapse_edge_inner` rejected. Rejections come from
1013    /// `check_inverted_faces`, and measurement shows this fixture produces zero
1014    /// of them even with the ridges — as does the flat grid, because collapsing to
1015    /// a midpoint inside a convex one-ring cannot flip a face normal. That path is
1016    /// covered by `test_collapse_retries_candidates_the_inversion_guard_rejected`
1017    /// below, which builds the non-convex case on purpose, and at the unit level by
1018    /// `ops::pending_edges_test::requeue_makes_a_declined_entry_reachable_again`;
1019    /// on production scans it fires 64-18466 times per pass.
1020    #[test]
1021    fn test_collapse_until_min_length_on_non_planar_grid() {
1022        let mut mg = build_grid(6);
1023
1024        let displaced: Vec<(VertexId, Vec3)> = mg
1025            .positions
1026            .iter()
1027            .map(|(v, &p)| {
1028                let ridge = ((p.x as i32 % 2) ^ (p.y as i32 % 2)) as f32;
1029                (v, Vec3::new(p.x, p.y, ridge * 1.5))
1030            })
1031            .collect();
1032        for (v, p) in displaced {
1033            mg.positions[v] = p;
1034        }
1035        mg.compute_vertex_normals();
1036
1037        let faces_before = mg.faces.len();
1038        mg.collapse_until_edges_above_min_length(3.0, &mut HashSet::new());
1039
1040        assert!(
1041            mesh_invariant_violations(&mg).is_empty(),
1042            "invariants violated: {:?}",
1043            mesh_invariant_violations(&mg)
1044        );
1045        assert!(
1046            mg.faces.len() < faces_before,
1047            "collapse made no progress on the ridged grid"
1048        );
1049    }
1050
1051    /// The deferred-requeue path: a candidate `can_collapse_edge_inner` rejected is
1052    /// pushed back onto the heap after the next successful collapse, instead of being
1053    /// dropped. `pop_live` consumes the heap entry without touching the map, so a
1054    /// rejected id that is not requeued stays pending with nothing pointing at it —
1055    /// unreachable for the rest of the run.
1056    ///
1057    /// Reaching a rejection at all takes deliberate construction; the grids above
1058    /// produce none. `check_inverted_faces` rejects only when moving a vertex to the
1059    /// edge midpoint flips a face normal, which needs the midpoint to land across the
1060    /// line through two of that vertex's *other* ring neighbours — impossible inside
1061    /// a convex one-ring. So `v` is pulled to just inside the line `y = 3` through its
1062    /// neighbours `(3,3)` and `(2,3)`, and `w` is pushed just across it. Edge `v-w` is
1063    /// len_sqr 0.0225, the shortest in the mesh, so it is popped first, and its
1064    /// midpoint at `y = 3.025` inverts the face `(v, (3,3), (2,3))`.
1065    ///
1066    /// The second edit is what makes the requeue observable: a collapsible short edge
1067    /// in the far corner, `len_sqr` 0.09. The run then goes reject `v-w` → collapse
1068    /// the corner edge → retry `v-w`. The corner collapse is far enough away that
1069    /// `v-w` is not in its `halfedges_to_check`, so the re-check pass cannot re-insert
1070    /// it; only the requeue can bring it back. Drop the requeue and the final
1071    /// `debug_assert!` in the op sees one pending edge and an empty `deferred`.
1072    #[test]
1073    fn test_collapse_retries_candidates_the_inversion_guard_rejected() {
1074        const MIN_LEN_SQR: f32 = 0.2;
1075
1076        let mut mg = build_grid(4);
1077
1078        let vertex_at = |mg: &MeshGraph, x: f32, y: f32| -> VertexId {
1079            mg.positions
1080                .iter()
1081                .find(|(_, p)| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6)
1082                .map(|(v, _)| v)
1083                .expect("grid has no vertex at that position")
1084        };
1085
1086        let v = vertex_at(&mg, 2.0, 2.0);
1087        let w = vertex_at(&mg, 1.0, 2.0);
1088        let corner = vertex_at(&mg, 0.0, 0.0);
1089        mg.positions[v] = Vec3::new(2.5, 2.95, 0.0);
1090        mg.positions[w] = Vec3::new(2.5, 3.1, 0.0);
1091        mg.positions[corner] = Vec3::new(0.7, 0.0, 0.0);
1092        mg.compute_vertex_normals();
1093
1094        let below = |mg: &MeshGraph| {
1095            mg.halfedges
1096                .values()
1097                .filter(|he| he.length_squared(mg) < MIN_LEN_SQR)
1098                .count()
1099        };
1100
1101        // Exactly two edges pending: the inverting `v-w` and the corner edge.
1102        assert_eq!(below(&mg), 4, "fixture should seed exactly two short edges");
1103        let faces_before = mg.faces.len();
1104
1105        mg.collapse_until_edges_above_min_length(MIN_LEN_SQR, &mut HashSet::new());
1106
1107        assert!(
1108            mesh_invariant_violations(&mg).is_empty(),
1109            "invariants violated: {:?}",
1110            mesh_invariant_violations(&mg)
1111        );
1112        // The rejected candidate sits at the front of the queue; the op must still
1113        // get past it and collapse the corner edge.
1114        assert!(
1115            mg.faces.len() < faces_before,
1116            "a rejected shortest edge stalled the whole op: {faces_before} -> {} faces",
1117            mg.faces.len()
1118        );
1119        // `v-w` inverts a face no later collapse repairs, so it stays pending - and
1120        // stays *retried*, which is the half a dropped requeue would lose.
1121        assert_eq!(
1122            below(&mg),
1123            2,
1124            "expected only the inverting edge to survive, found {} short halfedges",
1125            below(&mg)
1126        );
1127    }
1128
1129    #[cfg(feature = "gltf")]
1130    #[test]
1131    fn test_can_collapse_edge() {
1132        use crate::{integrations::gltf, utils::get_tracing_subscriber};
1133
1134        get_tracing_subscriber();
1135        let mut meshgraph = gltf::load("src/ops/glb/can_collapse_edge.glb").unwrap();
1136
1137        #[cfg(feature = "rerun")]
1138        meshgraph.log_rerun();
1139
1140        let mut v_top_id = VertexId::default();
1141        let mut v_bottom_id = VertexId::default();
1142
1143        for (v_id, pos) in &meshgraph.positions {
1144            if pos.x == 0.0 {
1145                if pos.y == -1.0 {
1146                    v_top_id = v_id;
1147                } else if pos.y == 1.0 {
1148                    v_bottom_id = v_id;
1149                }
1150            }
1151        }
1152
1153        if v_top_id == VertexId::default() {
1154            panic!("No top vertex found");
1155        }
1156
1157        if v_bottom_id == VertexId::default() {
1158            panic!("No bottom vertex found");
1159        }
1160
1161        let he_id = meshgraph.halfedge_from_to(v_top_id, v_bottom_id).unwrap();
1162
1163        let result = meshgraph.can_collapse_edge(he_id);
1164
1165        assert!(result);
1166
1167        #[cfg(feature = "rerun")]
1168        crate::RR.flush_blocking().unwrap();
1169    }
1170
1171    #[cfg(feature = "gltf")]
1172    #[test]
1173    fn test_cannot_collapse_edge() {
1174        use crate::{integrations::gltf, utils::get_tracing_subscriber};
1175
1176        get_tracing_subscriber();
1177        let mut meshgraph = gltf::load("src/ops/glb/cannot_collapse_edge.glb").unwrap();
1178
1179        #[cfg(feature = "rerun")]
1180        meshgraph.log_rerun();
1181
1182        let mut v_top_id = VertexId::default();
1183        let mut v_bottom_id = VertexId::default();
1184
1185        for (v_id, pos) in &meshgraph.positions {
1186            if pos.x == 0.0 {
1187                if pos.y == -1.0 {
1188                    v_top_id = v_id;
1189                } else if pos.y == 1.0 {
1190                    v_bottom_id = v_id;
1191                }
1192            }
1193        }
1194
1195        if v_top_id == VertexId::default() {
1196            panic!("No top vertex found");
1197        }
1198
1199        if v_bottom_id == VertexId::default() {
1200            panic!("No bottom vertex found");
1201        }
1202
1203        let he_id = meshgraph.halfedge_from_to(v_top_id, v_bottom_id).unwrap();
1204
1205        let result = meshgraph.can_collapse_edge(he_id);
1206
1207        assert!(!result);
1208
1209        #[cfg(feature = "rerun")]
1210        crate::RR.flush_blocking().unwrap();
1211    }
1212
1213    /// Collapse absorbs one endpoint into the other, so ids the caller marked can name
1214    /// vertices that no longer exist by the time it returns. `marked_vertices` is also
1215    /// extended with the vertices the cleanup creates, so a caller that only ever adds
1216    /// to it never notices unless the dead ones are pruned too.
1217    #[test]
1218    fn test_collapse_purges_dead_ids_from_marked_vertices() {
1219        let mut mg = build_grid(4);
1220        let mut marked: HashSet<VertexId> = mg.vertices.keys().collect();
1221        let before = marked.len();
1222
1223        mg.collapse_until_edges_above_min_length(1.5, &mut marked);
1224
1225        assert!(
1226            marked.len() < before,
1227            "no vertex was collapsed - test is vacuous"
1228        );
1229        for v_id in &marked {
1230            assert!(
1231                mg.vertices.contains_key(*v_id),
1232                "marked vertex {v_id:?} is dead"
1233            );
1234        }
1235    }
1236}