Skip to main content

mesh_graph/ops/
edit.rs

1use glam::Vec3;
2use hashbrown::{HashMap, HashSet};
3use itertools::Itertools;
4use slotmap::SparseSecondaryMap;
5use tracing::{error, instrument};
6
7use crate::{FaceId, HalfedgeId, MeshGraph, VertexId, error_none, utils::unwrap_or_return};
8
9pub struct MergeVertices {
10    pub removed_vertices: Vec<VertexId>,
11    pub removed_halfedges: Vec<HalfedgeId>,
12    pub removed_faces: Vec<FaceId>,
13}
14
15impl MeshGraph {
16    /// Merges the given vertices into a single vertex, reconnecting halfedges and faces as needed.
17    #[instrument(skip_all)]
18    pub fn merge_vertices(
19        &mut self,
20        vertices: impl IntoIterator<Item = VertexId>,
21    ) -> MergeVertices {
22        let vertex_ids: Vec<VertexId> = vertices
23            .into_iter()
24            .filter(|v| self.vertices.contains_key(*v))
25            .unique()
26            .collect();
27
28        if vertex_ids.len() < 2 {
29            return MergeVertices {
30                removed_vertices: Vec::new(),
31                removed_halfedges: Vec::new(),
32                removed_faces: Vec::new(),
33            };
34        }
35
36        let vertex_set: HashSet<VertexId> = vertex_ids.iter().copied().collect();
37        let survivor_id = vertex_ids[0];
38
39        // Compute average position
40        let mut avg_pos = Vec3::ZERO;
41        let mut count = 0.0;
42        for &v_id in &vertex_ids {
43            if let Some(&pos) = self.positions.get(v_id) {
44                avg_pos += pos;
45                count += 1.0;
46            }
47        }
48        if count > 0.0 {
49            avg_pos /= count;
50        }
51
52        // Find faces to remove: any face with 2+ vertices in the merge set becomes degenerate
53        let mut faces_to_remove = HashSet::new();
54        for &v_id in &vertex_ids {
55            for face_id in self.vertex_adjacent_faces(v_id) {
56                if faces_to_remove.contains(&face_id) {
57                    continue;
58                }
59                if let Some(face) = self.faces.get(face_id) {
60                    let merged_count = face
61                        .vertices(self)
62                        .filter(|v| vertex_set.contains(v))
63                        .count();
64                    if merged_count >= 2 {
65                        faces_to_remove.insert(face_id);
66                    }
67                }
68            }
69        }
70
71        // Determine which halfedges to remove vs make boundary
72        let mut halfedges_to_remove = HashSet::new();
73        for &face_id in &faces_to_remove {
74            let face_hes: Vec<_> = self
75                .halfedges
76                .iter()
77                .filter_map(|(he_id, he)| {
78                    if he.face == Some(face_id) {
79                        Some((he_id, *he))
80                    } else {
81                        None
82                    }
83                })
84                .collect();
85
86            for (he_id, he) in face_hes {
87                if let Some(twin_id) = he.twin
88                    && let Some(twin) = self.halfedges.get(twin_id)
89                {
90                    if twin.is_boundary() || twin.face.is_some_and(|f| faces_to_remove.contains(&f))
91                    {
92                        halfedges_to_remove.insert(he_id);
93                        halfedges_to_remove.insert(twin_id);
94                    } else {
95                        if let Some(he_mut) = self.halfedges.get_mut(he_id) {
96                            he_mut.face = None;
97                            he_mut.next = None;
98                        }
99                    }
100                }
101            }
102        }
103
104        // Remove faces from BVH and faces slotmap
105        for &face_id in &faces_to_remove {
106            if let Some(face) = self.faces.get(face_id) {
107                self.bvh.remove(face.index);
108            }
109            self.faces.remove(face_id);
110        }
111
112        // Collect outgoing halfedge cleanup info before removing halfedges
113        // (start_vertex lookup needs the twin to be present)
114        let mut outgoing_cleanup: Vec<(VertexId, HalfedgeId)> = Vec::new();
115        for &he_id in &halfedges_to_remove {
116            if let Some(he) = self.halfedges.get(he_id)
117                && let Some(start_v_id) = he.start_vertex(self)
118            {
119                outgoing_cleanup.push((start_v_id, he_id));
120            }
121        }
122
123        for (v_id, he_id) in outgoing_cleanup {
124            if let Some(out_hes) = self.outgoing_halfedges.get_mut(v_id) {
125                out_hes.retain(|id| *id != he_id);
126            }
127        }
128
129        // Remove halfedges
130        for &he_id in &halfedges_to_remove {
131            self.halfedges.remove(he_id);
132        }
133
134        let mut removed_vertices = Vec::with_capacity(vertex_ids.len() - 1);
135
136        // Merge non-survivor vertices into survivor
137        for &v_id in &vertex_ids[1..] {
138            if !self.vertices.contains_key(v_id) {
139                continue;
140            }
141
142            removed_vertices.push(v_id);
143
144            // Get incoming halfedges to v_id (twins of outgoing halfedges from v_id)
145            let incoming_he_ids: Vec<HalfedgeId> = self
146                .outgoing_halfedges
147                .get(v_id)
148                .map(|out_hes| {
149                    out_hes
150                        .iter()
151                        .filter_map(|he_id| self.halfedges.get(*he_id)?.twin)
152                        .collect()
153                })
154                .unwrap_or_default();
155
156            for he_id in incoming_he_ids {
157                if let Some(he) = self.halfedges.get_mut(he_id) {
158                    he.end_vertex = survivor_id;
159                }
160            }
161
162            // Transfer outgoing halfedges to survivor
163            let outgoing = self
164                .outgoing_halfedges
165                .get(v_id)
166                .cloned()
167                .unwrap_or_default();
168
169            if let Some(entry) = self.outgoing_halfedges.entry(survivor_id) {
170                entry.or_default().extend(outgoing);
171            }
172
173            self.remove_only_vertex(v_id);
174        }
175
176        let mut removed_halfedges: Vec<HalfedgeId> = halfedges_to_remove.into_iter().collect();
177        let removed_faces: Vec<FaceId> = faces_to_remove.into_iter().collect();
178
179        // Check if survivor still exists (could have been removed if all faces were removed)
180        if !self.vertices.contains_key(survivor_id) {
181            return MergeVertices {
182                removed_vertices,
183                removed_halfedges,
184                removed_faces,
185            };
186        }
187
188        self.positions[survivor_id] = avg_pos;
189
190        // Clean up stale outgoing halfedges for survivor
191        if let Some(out_hes) = self.outgoing_halfedges.get_mut(survivor_id) {
192            out_hes.retain(|he_id| self.halfedges.contains_key(*he_id));
193        }
194
195        // Remove duplicate halfedges that now share the same edge after vertex merge
196        {
197            let out_hes = self
198                .outgoing_halfedges
199                .get(survivor_id)
200                .cloned()
201                .unwrap_or_default();
202
203            let mut by_end: HashMap<VertexId, Vec<HalfedgeId>> = HashMap::new();
204            for &he_id in &out_hes {
205                if let Some(he) = self.halfedges.get(he_id) {
206                    by_end.entry(he.end_vertex).or_default().push(he_id);
207                }
208            }
209
210            let mut affected_vertices = HashSet::new();
211
212            for (end_v, group) in by_end {
213                if group.len() < 2 {
214                    continue;
215                }
216
217                // Pick best forward halfedge (prefer non-boundary)
218                let best_fwd = group
219                    .iter()
220                    .copied()
221                    .max_by_key(|&id| {
222                        self.halfedges
223                            .get(id)
224                            .map_or(0u8, |h| if h.face.is_some() { 1 } else { 0 })
225                    })
226                    .unwrap();
227
228                // Collect valid twins
229                let twins: Vec<HalfedgeId> = group
230                    .iter()
231                    .filter_map(|&id| {
232                        self.halfedges
233                            .get(id)?
234                            .twin
235                            .filter(|t| self.halfedges.contains_key(*t))
236                    })
237                    .collect();
238
239                // Pick best reverse halfedge (prefer non-boundary)
240                let best_rev = twins.iter().copied().max_by_key(|&id| {
241                    self.halfedges
242                        .get(id)
243                        .map_or(0u8, |h| if h.face.is_some() { 1 } else { 0 })
244                });
245
246                // Update twin pointers
247                if let Some(rev_id) = best_rev {
248                    if let Some(he) = self.halfedges.get_mut(best_fwd) {
249                        he.twin = Some(rev_id);
250                    }
251                    if let Some(he) = self.halfedges.get_mut(rev_id) {
252                        he.twin = Some(best_fwd);
253                    }
254                }
255
256                // Remove duplicate forward halfedges
257                for &he_id in &group {
258                    if he_id != best_fwd {
259                        self.halfedges.remove(he_id);
260                        removed_halfedges.push(he_id);
261                    }
262                }
263
264                // Remove duplicate reverse halfedges
265                for &twin_id in &twins {
266                    if Some(twin_id) != best_rev {
267                        self.halfedges.remove(twin_id);
268                        removed_halfedges.push(twin_id);
269                    }
270                }
271
272                affected_vertices.insert(end_v);
273            }
274
275            // Clean up outgoing halfedges for survivor
276            if let Some(out_hes) = self.outgoing_halfedges.get_mut(survivor_id) {
277                out_hes.retain(|he_id| self.halfedges.contains_key(*he_id));
278            }
279
280            // Clean up outgoing halfedges for affected adjacent vertices
281            for v in affected_vertices {
282                if let Some(out_hes) = self.outgoing_halfedges.get_mut(v) {
283                    out_hes.retain(|he_id| self.halfedges.contains_key(*he_id));
284                }
285                let new_out = self
286                    .outgoing_halfedges
287                    .get(v)
288                    .and_then(|hes| hes.first().copied());
289                if let Some(vertex) = self.vertices.get_mut(v) {
290                    vertex.outgoing_halfedge = new_out;
291                }
292            }
293        }
294
295        // Update outgoing_halfedge reference
296        let new_outgoing = self
297            .outgoing_halfedges
298            .get(survivor_id)
299            .and_then(|hes| hes.first().copied());
300
301        if let Some(vertex) = self.vertices.get_mut(survivor_id) {
302            vertex.outgoing_halfedge = new_outgoing;
303        }
304
305        self.make_outgoing_halfedge_boundary_if_possible(survivor_id);
306        self.compute_vertex_normal(survivor_id);
307
308        MergeVertices {
309            removed_vertices,
310            removed_halfedges,
311            removed_faces,
312        }
313    }
314
315    /// Flips this edge so that it represents the other diagonal described by the quad formed by the two incident triangles.
316    ///
317    /// ```text
318    ///     *                    *
319    ///    / \                  / \
320    ///   /   \                / ‖ \
321    ///  /     \              /  ‖  \
322    /// * ===== *     =>     *   ‖   *
323    ///  \     /              \  ‖  /
324    ///   \   /                \ ‖ /
325    ///    \ /                  \ /
326    ///     *                    *
327    /// ```
328    #[instrument(skip(self))]
329    pub fn flip_edge(&mut self, halfedge_id: HalfedgeId) {
330        #[cfg(feature = "rerun")]
331        self.log_he_rerun("flip", halfedge_id);
332
333        let he = unwrap_or_return!(self.halfedges.get(halfedge_id), "Halfedge not found");
334
335        let prev_he_id = unwrap_or_return!(he.prev(self), "Prev not found");
336        let prev_he = unwrap_or_return!(self.halfedges.get(prev_he_id), "Prev not found");
337        let start_v_id = prev_he.end_vertex;
338        let prev_twin_he_id = unwrap_or_return!(prev_he.twin, "Prev twin not found");
339
340        let next_he_id = unwrap_or_return!(he.next, "Next not found");
341        let next_he = unwrap_or_return!(self.halfedges.get(next_he_id), "Next not found");
342        let opposite_v_id = next_he.end_vertex;
343        let next_twin_he_id = unwrap_or_return!(next_he.twin, "Next twin not found");
344
345        let twin_he_id = unwrap_or_return!(he.twin, "Twin not found");
346        let twin_he = unwrap_or_return!(self.halfedges.get(twin_he_id), "Twin not found");
347
348        let twin_prev_he_id = unwrap_or_return!(twin_he.prev(self), "Prev not found");
349        let twin_prev_he = unwrap_or_return!(self.halfedges.get(twin_prev_he_id), "Prev not found");
350        let twin_start_v_id = twin_prev_he.end_vertex;
351        let twin_prev_twin_he_id = unwrap_or_return!(twin_prev_he.twin, "Prev twin twin not found");
352
353        let twin_next_he_id = unwrap_or_return!(twin_he.next, "Next not found");
354        let twin_next_he = unwrap_or_return!(self.halfedges.get(twin_next_he_id), "Next not found");
355        let twin_opposite_v_id = twin_next_he.end_vertex;
356        let twin_next_twin_he_id = unwrap_or_return!(twin_next_he.twin, "Next twin twin not found");
357
358        self.halfedges[halfedge_id].end_vertex = opposite_v_id;
359
360        self.halfedges[prev_he_id].end_vertex = twin_opposite_v_id;
361        self.make_twins(prev_he_id, twin_next_twin_he_id);
362        self.halfedges[next_he_id].end_vertex = start_v_id;
363        self.make_twins(next_he_id, prev_twin_he_id);
364
365        self.remove_outgoing_halfedge(start_v_id, halfedge_id);
366        self.remove_outgoing_halfedge(start_v_id, twin_next_he_id);
367        self.add_outgoing_halfedge(start_v_id, prev_he_id);
368
369        self.remove_outgoing_halfedge(opposite_v_id, prev_he_id);
370        self.add_outgoing_halfedge(opposite_v_id, next_he_id);
371        self.add_outgoing_halfedge(opposite_v_id, twin_he_id);
372
373        self.halfedges[twin_he_id].end_vertex = twin_opposite_v_id;
374
375        self.halfedges[twin_prev_he_id].end_vertex = opposite_v_id;
376        self.make_twins(twin_prev_he_id, next_twin_he_id);
377        self.halfedges[twin_next_he_id].end_vertex = twin_start_v_id;
378        self.make_twins(twin_next_he_id, twin_prev_twin_he_id);
379
380        self.remove_outgoing_halfedge(twin_start_v_id, twin_he_id);
381        self.remove_outgoing_halfedge(twin_start_v_id, next_he_id);
382        self.add_outgoing_halfedge(twin_start_v_id, twin_prev_he_id);
383
384        self.remove_outgoing_halfedge(twin_opposite_v_id, twin_prev_he_id);
385        self.add_outgoing_halfedge(twin_opposite_v_id, twin_next_he_id);
386        self.add_outgoing_halfedge(twin_opposite_v_id, halfedge_id);
387
388        // checked if halfedge exists above
389        let face_id1 = unwrap_or_return!(self.halfedges[halfedge_id].face, "Face not found");
390        let face1 = unwrap_or_return!(self.faces.get(face_id1), "Face not found");
391        self.bvh
392            .insert_or_update_partially(face1.aabb(self), face1.index, 0.0);
393
394        // checked if halfedge exists above
395        let face_id2 = unwrap_or_return!(self.halfedges[twin_he_id].face, "Face not found");
396        let face2 = unwrap_or_return!(self.faces.get(face_id2), "Face not found");
397        self.bvh
398            .insert_or_update_partially(face2.aabb(self), face2.index, 0.0);
399    }
400
401    /// Makes two halfedges twins of each other. Doesn't change anything else
402    pub fn make_twins(&mut self, he_id1: HalfedgeId, he_id2: HalfedgeId) {
403        let he1 = unwrap_or_return!(self.halfedges.get_mut(he_id1), "Halfedge not found");
404        he1.twin = Some(he_id2);
405
406        let he2 = unwrap_or_return!(self.halfedges.get_mut(he_id2), "Halfedge not found");
407        he2.twin = Some(he_id1);
408    }
409
410    /// Removes the outgoing halfedge from a vertex. Doesn't change anything else.
411    #[instrument(skip(self))]
412    pub fn remove_outgoing_halfedge(&mut self, vertex_id: VertexId, halfedge_id: HalfedgeId) {
413        let outgoing_halfedges = unwrap_or_return!(
414            self.outgoing_halfedges.get_mut(vertex_id),
415            "No outgoing halfedges found"
416        );
417
418        outgoing_halfedges.retain(|he_id| *he_id != halfedge_id);
419    }
420
421    /// Adds the outgoing halfedge to a vertex and overrides the vertex.outgoing_halfedge
422    #[instrument(skip(self))]
423    pub fn add_outgoing_halfedge(&mut self, vertex_id: VertexId, outgoing_halfedge: HalfedgeId) {
424        let vertex = unwrap_or_return!(self.vertices.get_mut(vertex_id), "Vertex not found");
425        vertex.outgoing_halfedge = Some(outgoing_halfedge);
426
427        let v_outgoing_halfedges = unwrap_or_return!(
428            self.outgoing_halfedges.get_mut(vertex_id),
429            "No outgoing halfedges found"
430        );
431        v_outgoing_halfedges.push(outgoing_halfedge);
432    }
433
434    /// Smooths the position of the vertex by computing the average of its own and its neighbors' positions and
435    /// moving it there. Also called Laplacian Smoothing.
436    #[instrument(skip_all)]
437    pub fn smooth_vertices(&mut self, vertices: impl IntoIterator<Item = VertexId>) {
438        let mut new_positions = SparseSecondaryMap::new();
439
440        let mut affected_face_ids = HashSet::new();
441
442        for vertex_id in vertices.into_iter() {
443            let Some(pos) = self.compute_smoothed_vertex_pos(vertex_id) else {
444                continue;
445            };
446
447            new_positions.insert(vertex_id, pos);
448
449            // vertex checked if exists in `compute_smoothed_vertex_pos()`
450            affected_face_ids.extend(self.vertices[vertex_id].faces(self));
451        }
452
453        for (vertex_id, &pos) in &new_positions {
454            self.positions.insert(vertex_id, pos);
455        }
456
457        for vertex_id in new_positions.keys() {
458            self.compute_vertex_normal(vertex_id);
459        }
460
461        for face_id in affected_face_ids {
462            let Some(face) = self.faces.get(face_id) else {
463                error!("Face {:?} does not exist", face_id);
464                continue;
465            };
466
467            self.bvh
468                .insert_or_update_partially(face.aabb(self), face.index, 0.0);
469        }
470    }
471
472    /// Smooth the position of the vertex by computing the average of its own and its neighbors' positions and
473    /// moving it there. Also called Laplacian Smoothing.
474    ///
475    /// > Note: If you want to smooth multiple vertices, use the `smooth_vertices` method instead of
476    /// > calling this method multiple times.
477    #[instrument(skip(self))]
478    pub fn smooth_vertex(&mut self, vertex_id: VertexId) {
479        let pos = unwrap_or_return!(
480            self.compute_smoothed_vertex_pos(vertex_id),
481            "Couldn't compute smoothed position"
482        );
483
484        self.positions.insert(vertex_id, pos);
485        self.compute_vertex_normal(vertex_id);
486
487        // vertex checked if exists in `compute_smoothed_vertex_pos()`
488        for face_id in self.vertices[vertex_id].faces(self).collect_vec() {
489            let Some(face) = self.faces.get(face_id) else {
490                error!("Face {:?} does not exist", face_id);
491                continue;
492            };
493
494            self.bvh
495                .insert_or_update_partially(face.aabb(self), face.index, 0.0);
496        }
497    }
498
499    #[instrument(skip(self))]
500    fn compute_smoothed_vertex_pos(&mut self, vertex_id: VertexId) -> Option<Vec3> {
501        let vertex = self.vertices.get(vertex_id)?;
502
503        let mut pos = *self
504            .positions
505            .get(vertex_id)
506            .or_else(error_none!("Position not found for id {vertex_id:?}"))?;
507
508        let mut count = 1.0;
509        for neighbor_v_id in vertex.neighbours(self) {
510            let neighbor_pos = *self.positions.get(neighbor_v_id).or_else(error_none!(
511                "Neighbor position not found for id {neighbor_v_id:?}"
512            ))?;
513
514            pos += neighbor_pos;
515            count += 1.0;
516        }
517
518        pos /= count;
519
520        Some(pos)
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use glam::Vec3;
527
528    use super::*;
529
530    #[cfg(feature = "gltf")]
531    #[test]
532    fn test_merge_vertices_cube() {
533        use crate::{integrations::gltf, utils::get_tracing_subscriber};
534
535        get_tracing_subscriber();
536        let mut meshgraph = gltf::load("src/ops/glb/cube.glb").unwrap();
537
538        #[cfg(feature = "rerun")]
539        meshgraph.log_rerun();
540
541        let x0_vertices: Vec<VertexId> = meshgraph
542            .positions
543            .iter()
544            .filter_map(|(v_id, pos)| if pos.x == 0.0 { Some(v_id) } else { None })
545            .collect();
546
547        let x0_count = x0_vertices.len();
548        assert!(x0_count >= 2, "Expected at least 2 vertices with x=0");
549
550        let MergeVertices {
551            removed_vertices,
552            removed_halfedges,
553            removed_faces,
554        } = meshgraph.merge_vertices(x0_vertices);
555
556        #[cfg(feature = "rerun")]
557        {
558            meshgraph.log_rerun();
559            crate::RR.flush_blocking().unwrap();
560        }
561
562        let remaining_x0: Vec<_> = meshgraph
563            .positions
564            .iter()
565            .filter(|(_, pos)| pos.x == 0.0)
566            .collect();
567
568        assert_eq!(remaining_x0.len(), 1);
569
570        assert_eq!(meshgraph.vertices.len(), 5);
571        assert_eq!(meshgraph.halfedges.len(), 18);
572        assert_eq!(meshgraph.faces.len(), 6);
573
574        assert_eq!(removed_vertices.len(), 3);
575        assert_eq!(removed_halfedges.len(), 18);
576        assert_eq!(removed_faces.len(), 6);
577    }
578
579    #[cfg(feature = "gltf")]
580    #[test]
581    fn test_merge_vertices_cube_w_missing_triangle() {
582        use crate::{integrations::gltf, utils::get_tracing_subscriber};
583
584        get_tracing_subscriber();
585        let mut meshgraph = gltf::load("src/ops/glb/cube_w_missing_triangle.glb").unwrap();
586
587        #[cfg(feature = "rerun")]
588        meshgraph.log_rerun();
589
590        let x0_vertices: Vec<VertexId> = meshgraph
591            .positions
592            .iter()
593            .filter_map(|(v_id, pos)| if pos.x == 0.0 { Some(v_id) } else { None })
594            .collect();
595
596        let x0_count = x0_vertices.len();
597        assert!(x0_count >= 2, "Expected at least 2 vertices with x=0");
598
599        let MergeVertices {
600            removed_vertices,
601            removed_halfedges,
602            removed_faces,
603        } = meshgraph.merge_vertices(x0_vertices);
604
605        #[cfg(feature = "rerun")]
606        {
607            meshgraph.log_rerun();
608            crate::RR.flush_blocking().unwrap();
609        }
610
611        let remaining_x0: Vec<_> = meshgraph
612            .positions
613            .iter()
614            .filter(|(_, pos)| pos.x == 0.0)
615            .collect();
616
617        assert_eq!(remaining_x0.len(), 1);
618
619        assert_eq!(meshgraph.vertices.len(), 5);
620        assert_eq!(meshgraph.halfedges.len(), 18);
621        assert_eq!(meshgraph.faces.len(), 6);
622
623        assert_eq!(removed_vertices.len(), 3);
624        assert_eq!(removed_halfedges.len(), 18);
625        assert_eq!(removed_faces.len(), 5);
626    }
627
628    #[test]
629    fn test_flip_edge() {
630        let mut mesh_graph = MeshGraph::new();
631
632        let v_id1 = mesh_graph.add_vertex(Vec3::new(-1.0, 1.0, 0.0));
633        let v_id2 = mesh_graph.add_vertex(Vec3::new(-1.0, -1.0, 0.0));
634        let v_id3 = mesh_graph.add_vertex(Vec3::new(1.0, -1.0, 0.0));
635        let v_id4 = mesh_graph.add_vertex(Vec3::new(1.0, 1.0, 0.0));
636
637        mesh_graph.add_face_from_vertices(v_id1, v_id2, v_id3);
638        mesh_graph.add_face_from_vertices(v_id1, v_id3, v_id4);
639
640        #[cfg(feature = "rerun")]
641        mesh_graph.log_rerun();
642
643        assert!(mesh_graph.halfedge_from_to(v_id2, v_id4).is_none());
644
645        assert_eq!(mesh_graph.outgoing_halfedges[v_id1].len(), 3);
646        assert_eq!(mesh_graph.outgoing_halfedges[v_id2].len(), 2);
647        assert_eq!(mesh_graph.outgoing_halfedges[v_id3].len(), 3);
648        assert_eq!(mesh_graph.outgoing_halfedges[v_id4].len(), 2);
649
650        mesh_graph.flip_edge(mesh_graph.halfedge_from_to(v_id1, v_id3).unwrap());
651
652        #[cfg(feature = "rerun")]
653        {
654            mesh_graph.log_rerun();
655            crate::RR.flush_blocking().unwrap();
656        }
657
658        assert!(mesh_graph.halfedge_from_to(v_id1, v_id3).is_none());
659        assert!(mesh_graph.halfedge_from_to(v_id2, v_id4).is_some());
660
661        assert_eq!(mesh_graph.outgoing_halfedges[v_id1].len(), 2);
662        assert_eq!(mesh_graph.outgoing_halfedges[v_id2].len(), 3);
663        assert_eq!(mesh_graph.outgoing_halfedges[v_id3].len(), 2);
664        assert_eq!(mesh_graph.outgoing_halfedges[v_id4].len(), 3);
665    }
666}