Skip to main content

mesh_graph/
lib.rs

1//! MeshGraph is a halfedge data structure for representing triangle meshes.
2//!
3//! This is heavily inspired by [SMesh](https://github.com/Bendzae/SMesh) and
4//! [OpenMesh](https://gitlab.vci.rwth-aachen.de:9000/OpenMesh/OpenMesh).
5//!
6//! ## Features
7//!
8//! - Fast spatial queries using parry3d's Bvh
9//! - High performance using slotmap
10//! - Easy integration with Bevy game engine using the `bevy` Cargo feature
11//! - Good debugging using `rerun` Cargo feature to enable the Rerun integration
12//! - Best in class documentation with illustrations
13//!
14//! ### Debugging topology corruption
15//!
16//! The `instrumentation` Cargo feature compiles in extra topology probes: chain /
17//! twin / outgoing-list validators that run at the end of every topology op and
18//! report the first op to corrupt the mesh, plus JSON state dump/resume via
19//! `MeshGraph::save_state` / `MeshGraph::load_state`. Enabling the feature enables
20//! the probes — there is no second switch to forget — and regular builds compile
21//! none of it.
22//!
23//! The validators scan every halfedge at the end of every topology op, so the cost
24//! grows with the mesh: roughly 30 ms per op call on a 250k-halfedge mesh. That is
25//! nothing for the per-stroke `*_until_*` ops but very noticeable for a host that
26//! calls `merge_vertices_one_rings` hundreds of times per stroke. The further
27//! extras stay opt-in via the environment:
28//!
29//! - `MESH_GRAPH_STATE_HISTORY_LEN=<n>` keeps a ring of the last `n` verified mesh
30//!   states (a full mesh clone per op) and writes it to disk when a probe fires, so
31//!   the run can be resumed from any state leading up to the corruption. Default `0`.
32//! - `MESH_GRAPH_HOLE_CHECK=1` adds the boundary-delta probe: every probed op must
33//!   leave the set of open edges exactly as it found it.
34//! - `MESH_GRAPH_TRACE=1` records a ring of the structural re-wiring events leading
35//!   up to a report.
36//!
37//! ## Usage
38//!
39//! ```
40//! use mesh_graph::{MeshGraph, primitives::IcoSphere};
41//!
42//! // Create a new mesh
43//! let mesh_graph = MeshGraph::from(IcoSphere { radius: 10.0, subdivisions: 2 });
44//!
45//! // Get some vertex ID and its vertex node
46//! let (vertex_id, vertex) = mesh_graph.vertices.iter().next().unwrap();
47//!
48//! // Iterate over all outgoing halfedges of the vertex
49//! for halfedge_id in vertex.outgoing_halfedges(&mesh_graph) {
50//!     // do sth
51//! }
52//!
53//! // Get the position of the vertex
54//! let position = mesh_graph.positions[vertex_id];
55//! ```
56//!
57//! Check out the crate [freestyle-sculpt](https://github.com/Synphonyte/freestyle-sculpt) for
58//! a heavy duty example.
59//!
60//! ## Connectivity
61//!
62//! ### Halfedge
63//!
64//! <img src="https://raw.githubusercontent.com/Synphonyte/mesh-graph/refs/heads/main/docs/halfedge/all.svg" alt="Connectivity" style="max-width: 28em" />
65//!
66//! ### Vertex
67//!
68//! <img src="https://raw.githubusercontent.com/Synphonyte/mesh-graph/refs/heads/main/docs/vertex/all.svg" alt="Connectivity" style="max-width: 50em" />
69
70mod access;
71mod elements;
72pub mod integrations;
73mod iter;
74mod ops;
75mod plane_slice;
76pub mod primitives;
77#[cfg(feature = "rerun")]
78mod rerun_impl;
79mod selection;
80#[cfg(feature = "serde")]
81mod serialize;
82pub mod utils;
83
84pub use elements::*;
85pub use iter::*;
86pub use ops::*;
87pub use plane_slice::*;
88pub use selection::*;
89
90use hashbrown::HashMap;
91use parry3d::partitioning::{Bvh, BvhWorkspace};
92
93use glam::Vec3;
94use slotmap::{SecondaryMap, SlotMap};
95
96use tracing::{error, instrument};
97
98use crate::utils::unwrap_or_return;
99
100#[cfg(feature = "instrumentation")]
101// Debug support for the corruption probes: the name of the operation currently
102// running on this thread. Set by the public ops (collapse/subdivide/merge/...) and
103// recorded next to every face removal in [`record_face_death`], so a corruption
104// report can name the op that killed a face.
105thread_local! {
106    static CURRENT_OP: std::cell::Cell<&'static str> = const { std::cell::Cell::new("unknown") };
107}
108
109/// Records the current op name on this thread (see [`CURRENT_OP`]).
110#[cfg(feature = "instrumentation")]
111#[inline]
112pub(crate) fn set_current_op(op: &'static str) {
113    CURRENT_OP.with(|cell| cell.set(op));
114}
115
116/// A small ring of the most recent face removals: `(op that removed it, face id)`.
117/// Printed by the corruption reports (see [`record_face_death`]).
118#[cfg(feature = "instrumentation")]
119static FACE_DEATH_LEDGER: std::sync::Mutex<std::collections::VecDeque<(FaceId, &'static str)>> =
120    std::sync::Mutex::new(std::collections::VecDeque::new());
121
122#[cfg(feature = "instrumentation")]
123const FACE_DEATH_LEDGER_CAP: usize = 64;
124
125/// Records a face removal for the corruption reports: a mutex-guarded push onto a
126/// 64-entry ring, nothing more.
127#[cfg(feature = "instrumentation")]
128#[inline]
129pub(crate) fn record_face_death(face_id: FaceId) {
130    let op = CURRENT_OP.with(|cell| cell.get());
131    if let Ok(mut ledger) = FACE_DEATH_LEDGER.lock() {
132        ledger.push_back((face_id, op));
133        if ledger.len() > FACE_DEATH_LEDGER_CAP {
134            ledger.pop_front();
135        }
136    }
137}
138
139// Per-op boundary capture for the hole-delta probe (`MESH_GRAPH_HOLE_CHECK=1`):
140// the undirected `(he, twin)` pairs of
141// all live halfedges with `face = None`, snapshotted at the entry of each probed
142// op (collapse/subdivide/merge/...). Every probed op is required to leave this
143// exact set untouched — a topology op that opens or closes the surface mid-op is
144// corruption (see `probe_chain_integrity`). Thread-local because the unit tests
145// run ops of independent meshes on parallel threads.
146#[cfg(feature = "instrumentation")]
147thread_local! {
148    static OP_BOUNDARY: std::cell::RefCell<Option<hashbrown::HashSet<(HalfedgeId, HalfedgeId)>>> =
149        const { std::cell::RefCell::new(None) };
150}
151
152/// Whether the hole-delta probe runs. It costs a full halfedge scan at both ends
153/// of every probed op, so it is opt-in with `MESH_GRAPH_HOLE_CHECK=1`.
154#[cfg(feature = "instrumentation")]
155pub(crate) fn hole_check_enabled() -> bool {
156    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
157    *ENABLED.get_or_init(|| std::env::var_os("MESH_GRAPH_HOLE_CHECK").is_some())
158}
159
160/// Must be called at the entry of every probed op (next to [`set_current_op`]).
161/// Snapshots the boundary edge set so the op-end probe can compare against it.
162/// Unprobed ops in between (the `remove_face`-family punch cuts) never update the
163/// snapshot, so a hole cut between two probed ops is never blamed on either of
164/// them — each probed op is only held accountable for its own delta.
165#[cfg(feature = "instrumentation")]
166#[inline]
167pub(crate) fn probe_chain_begin(mesh: &MeshGraph) {
168    if !hole_check_enabled() {
169        return;
170    }
171    let boundary = mesh.boundary_edge_set();
172    OP_BOUNDARY.with(|b| *b.borrow_mut() = Some(boundary));
173}
174
175/// Prints the added/removed boundary edges of a boundary-set delta (hole-delta
176/// probes). Up to 8 samples of each side.
177#[cfg(feature = "instrumentation")]
178fn dump_boundary_delta(added: &[(HalfedgeId, HalfedgeId)], removed: &[(HalfedgeId, HalfedgeId)]) {
179    if !added.is_empty() {
180        eprintln!("  added {}:", added.len());
181        for (a, b) in added.iter().take(8) {
182            eprintln!("    edge ({a:?}, {b:?})");
183        }
184    }
185    if !removed.is_empty() {
186        eprintln!("  removed {}:", removed.len());
187        for (a, b) in removed.iter().take(8) {
188            eprintln!("    edge ({a:?}, {b:?})");
189        }
190    }
191}
192
193/// Prints the face-death ledger (most recent last).
194#[cfg(feature = "instrumentation")]
195pub(crate) fn dump_face_death_ledger() {
196    if let Ok(ledger) = FACE_DEATH_LEDGER.lock() {
197        for (face_id, op) in ledger.iter() {
198            eprintln!("    face {face_id:?} removed by '{op}'");
199        }
200    }
201}
202
203/// Called when `collapse_until_edges_above_min_length`'s neighborhood check picks a
204/// dead halfedge id (inserted via `he.twin` of a live halfedge without a liveness
205/// check on the twin). Scans the neighborhood for the twin violator — the live
206/// halfedge whose `twin` references the dead id — and dumps it.
207#[cfg(feature = "instrumentation")]
208#[inline]
209pub(crate) fn report_dead_halfedge_in_collapse_check(mesh_graph: &MeshGraph, dead_id: HalfedgeId) {
210    static REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
211    if REPORTED.set(()).is_err() {
212        return;
213    }
214    mark_integrity_violation();
215    eprintln!("DEAD ID IN COLLAPSE CHECK: {dead_id:?} was inserted via a live halfedge's twin");
216    for (he_id, he) in &mesh_graph.halfedges {
217        if he.twin == Some(dead_id) {
218            let face_alive = he.face.is_some_and(|f| mesh_graph.faces.contains_key(f));
219            eprintln!(
220                "  violator {he_id:?}: face={:?} (alive={face_alive}) next={:?} twin={:?} end={:?}",
221                he.face, he.next, he.twin, he.end_vertex
222            );
223        }
224    }
225    eprintln!("{}", std::backtrace::Backtrace::force_capture());
226    eprintln!("recent face deaths (oldest first):");
227    dump_face_death_ledger();
228    state_history_dump(
229        "dead_id_in_collapse_check",
230        Some(mesh_graph),
231        Some(&dead_id),
232    );
233}
234
235// -- state history (clone ring + resume) ----------------------------------------
236//
237// Compiled only with the `instrumentation` feature: after every op end that passes
238// the chain-integrity validator a clone of the mesh is pushed onto a small ring.
239// When a corruption probe fires, the ring is written to disk so the op that
240// introduced the corruption can be diagnosed from the states leading up to it, and
241// the run can be resumed from any of them via `MeshGraph::load_state`.
242//
243// The ring clones the entire mesh per op, so unlike the validators it is opt-in.
244//
245// Env vars:
246//   MESH_GRAPH_STATE_HISTORY_LEN     – ring capacity (default 0 = ring disabled;
247//                                      also settable per thread with
248//                                      `set_state_history_len`)
249//   MESH_GRAPH_STATE_DUMP_DIR        – dump directory (default `mesh_graph_state_dump_<pid>`)
250//   MESH_GRAPH_STATE_DUMP_AT_POS     – dump the ring (once) when the replay position
251//                                      reaches this value (capture-on-demand for tests)
252//   MESH_GRAPH_TRACE                 – record the structural re-wiring events leading
253//                                      up to a report
254//   MESH_GRAPH_HOLE_CHECK            – boundary-delta probe: each probed op must leave
255//                                      the boundary edge set unchanged vs. its own entry
256//                                      (per-op snapshot by `probe_chain_begin`); closed
257//                                      regions mark violations, open (punch-rim) regions
258//                                      only report
259//
260// The replay position is a *step index*: the host reports one step per mesh-graph
261// topology op call (collapse / subdivide / individual merge_one_ring) via
262// [`MeshGraph::set_replay_position`], so ring snapshots map 1:1 to the host's
263// operation journal and a run can be resumed from any dumped state by replaying
264// the remaining journal steps (freestyle-sculpt: `MESH_GRAPH_RESUME_STATE` +
265// `MESH_GRAPH_RESUME_INDEX`).
266
267/// A short trace of the structural re-wiring events (flips, flap removals, fresh
268/// boundary pairings, ...) that led up to a corruption report. Each entry records
269/// the event kind plus the halfedges/vertices it touched, so the corruption report
270/// can show which writer produced the violated ids instead of guessing from the
271/// mesh diff. Ring of the last [`OP_TRACE_CAP`] events; printed by the corruption
272/// report on demand.
273#[cfg(feature = "instrumentation")]
274pub(crate) static OP_TRACE: std::sync::Mutex<std::collections::VecDeque<String>> =
275    std::sync::Mutex::new(std::collections::VecDeque::new());
276
277#[cfg(feature = "instrumentation")]
278pub(crate) const OP_TRACE_CAP: usize = 2000;
279
280/// Whether the op trace records events. Every event allocates a formatted `String`,
281/// so it is opt-in with `MESH_GRAPH_TRACE=1`.
282#[cfg(feature = "instrumentation")]
283pub(crate) fn op_trace_enabled() -> bool {
284    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
285    *ENABLED.get_or_init(|| std::env::var_os("MESH_GRAPH_TRACE").is_some())
286}
287
288/// Records one structural re-wiring event into [`OP_TRACE`] (a no-op — including the
289/// `format!` itself — unless `MESH_GRAPH_TRACE=1`).
290#[cfg(feature = "instrumentation")]
291#[doc(hidden)]
292#[macro_export]
293macro_rules! record_op_trace {
294    ($($arg:tt)*) => {
295        if $crate::op_trace_enabled() {
296            $crate::record_op_trace_impl(format_args!($($arg)*).to_string());
297        }
298    };
299}
300
301#[cfg(feature = "instrumentation")]
302#[inline]
303pub(crate) fn record_op_trace_impl(event: String) {
304    if let Ok(mut trace) = OP_TRACE.lock() {
305        trace.push_back(event);
306        while trace.len() > OP_TRACE_CAP {
307            trace.pop_front();
308        }
309    }
310}
311
312/// The replay position (input-log entry index) reported by the host application
313/// through [`MeshGraph::set_replay_position`]. Stored with every state snapshot so
314/// a dumped state can be resumed at the exact spot it was captured.
315#[cfg(feature = "instrumentation")]
316static REPLAY_POSITION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
317
318/// Reports the host's current replay position (input-log entry index). The host
319/// calls [`MeshGraph::set_replay_position`] for every consumed entry; state
320/// snapshots record it so a dumped state can be resumed at the exact spot it was
321/// captured.
322#[cfg(feature = "instrumentation")]
323pub fn set_replay_position(pos: u64) {
324    REPLAY_POSITION.store(pos, std::sync::atomic::Ordering::Relaxed);
325}
326
327#[cfg(feature = "instrumentation")]
328fn replay_position() -> u64 {
329    REPLAY_POSITION.load(std::sync::atomic::Ordering::Relaxed)
330}
331
332#[cfg(feature = "instrumentation")]
333thread_local! {
334    /// Set on the first integrity-violation report on this thread, so hosts and
335    /// tests can assert that a replay of a dumped state stayed clean.
336    ///
337    /// Per-thread rather than process-global, matching the rest of the instrumentation
338    /// state: ops always run on the caller's thread, and the test harness gives each
339    /// test its own, so a violation raised by one replay is never attributed to another
340    /// running beside it. Pair it with [`reset_integrity_violation`] when several
341    /// replays share a thread.
342    static INTEGRITY_VIOLATION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
343}
344
345/// Marks the first detected integrity violation (see [`integrity_violation_reported`]).
346#[cfg(feature = "instrumentation")]
347pub(crate) fn mark_integrity_violation() {
348    INTEGRITY_VIOLATION.with(|cell| cell.set(true));
349}
350
351/// Returns `true` once any corruption report has fired on this thread. Hunt
352/// instrumentation only; always `false` without the `instrumentation` feature.
353#[cfg(feature = "instrumentation")]
354pub fn integrity_violation_reported() -> bool {
355    INTEGRITY_VIOLATION.with(|cell| cell.get())
356}
357
358/// Clears this thread's violation flag, so a caller can assert that one specific
359/// replay stayed clean regardless of what ran on the thread before it.
360#[cfg(feature = "instrumentation")]
361pub fn reset_integrity_violation() {
362    INTEGRITY_VIOLATION.with(|cell| cell.set(false));
363}
364
365/// One verified mesh state: the mesh clone plus the position/op it was captured at.
366#[cfg(feature = "instrumentation")]
367struct StateSnapshot {
368    pos: u64,
369    op: &'static str,
370    mesh: MeshGraph,
371}
372
373/// The ring of the most recent verified states (oldest first).
374#[cfg(feature = "instrumentation")]
375pub(crate) static STATE_RING: std::sync::Mutex<std::collections::VecDeque<StateSnapshot>> =
376    std::sync::Mutex::new(std::collections::VecDeque::new());
377
378#[cfg(feature = "instrumentation")]
379// Ring capacity for this thread; `None` until first read from the environment.
380// Per-thread so a host thread can opt in without the library's parallel unit tests
381// pushing into the same ring.
382thread_local! {
383    static STATE_RING_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
384}
385
386/// Set once the state history has been dumped, so hosts can write sidecar data
387/// (e.g. an operation journal) into the same directory.
388#[cfg(feature = "instrumentation")]
389static STATE_DUMP_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
390
391/// The directory the state history was dumped to (if a dump happened in this
392/// process). Hosts can use it to write sidecar files (like an operation journal)
393/// next to the dumped states.
394#[cfg(feature = "instrumentation")]
395pub fn state_dump_dir() -> Option<std::path::PathBuf> {
396    STATE_DUMP_DIR.get().cloned()
397}
398
399/// The state-history ring capacity for the current thread. Defaults to `0` — the
400/// ring clones the whole mesh at every op end, far and away the most expensive
401/// part of the instrumentation, so it is opt-in via `MESH_GRAPH_STATE_HISTORY_LEN`
402/// or [`set_state_history_len`].
403#[cfg(feature = "instrumentation")]
404fn state_ring_cap() -> usize {
405    STATE_RING_CAP.with(|cap| match cap.get() {
406        Some(cap) => cap,
407        None => {
408            let from_env = std::env::var_os("MESH_GRAPH_STATE_HISTORY_LEN")
409                .and_then(|s| s.to_str().and_then(|s| s.parse::<usize>().ok()))
410                .unwrap_or(0);
411            cap.set(Some(from_env));
412            from_env
413        }
414    })
415}
416
417/// Sets the state-history ring capacity for the current thread, overriding
418/// `MESH_GRAPH_STATE_HISTORY_LEN`. `0` disables the ring (the default), so a host
419/// that wants resumable snapshots has to ask for them — see [`state_ring_cap`].
420#[cfg(feature = "instrumentation")]
421pub fn set_state_history_len(len: usize) {
422    STATE_RING_CAP.with(|cap| cap.set(Some(len)));
423}
424
425/// The replay position at which the ring is dumped once (`MESH_GRAPH_STATE_DUMP_AT_POS`).
426#[cfg(feature = "instrumentation")]
427fn state_dump_at_pos() -> Option<u64> {
428    static AT_POS: std::sync::OnceLock<Option<u64>> = std::sync::OnceLock::new();
429    *AT_POS.get_or_init(|| {
430        std::env::var_os("MESH_GRAPH_STATE_DUMP_AT_POS")
431            .and_then(|s| s.to_str().and_then(|s| s.parse::<u64>().ok()))
432    })
433}
434
435/// Pushes a clone of the current mesh onto the state-history ring. Only called at
436/// op ends that passed the chain-integrity validator, and only when the ring has
437/// been opted into (see [`state_ring_cap`]) — the mesh clone happens after that
438/// check, never speculatively.
439#[cfg(feature = "instrumentation")]
440#[inline]
441pub(crate) fn state_history_push(mesh: &MeshGraph, op: &'static str) {
442    let cap = state_ring_cap();
443    if cap == 0 {
444        return;
445    }
446
447    let snapshot = StateSnapshot {
448        pos: replay_position(),
449        op,
450        mesh: mesh.clone(),
451    };
452    if let Ok(mut ring) = STATE_RING.lock() {
453        ring.push_back(snapshot);
454        while ring.len() > cap {
455            ring.pop_front();
456        }
457    }
458
459    // Capture-on-demand: dump the ring (once) once the boom position is reached.
460    if let Some(target) = state_dump_at_pos()
461        && replay_position() >= target
462    {
463        state_history_dump("at_position", Some(mesh), None);
464    }
465}
466
467/// Writes the state-history ring to disk (plus an optional `current` state), so
468/// the states leading up to a corruption event can be inspected/resumed from.
469/// Fires at most once per process. Needs the `serde` feature (JSON state files).
470#[cfg(feature = "instrumentation")]
471pub(crate) fn state_history_dump(
472    reason: &str,
473    current: Option<&MeshGraph>,
474    context: Option<&dyn std::fmt::Debug>,
475) {
476    static DUMPED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
477    if DUMPED.set(()).is_err() {
478        return;
479    }
480
481    let dir = std::env::var_os("MESH_GRAPH_STATE_DUMP_DIR")
482        .map(std::path::PathBuf::from)
483        .unwrap_or_else(|| {
484            std::path::PathBuf::from(format!("mesh_graph_state_dump_{}", std::process::id()))
485        });
486    if let Err(e) = std::fs::create_dir_all(&dir) {
487        eprintln!("state dump: could not create {}: {e:?}", dir.display());
488        return;
489    }
490    // Advertise the dump directory so hosts can write sidecars (e.g. the op
491    // journal) into it.
492    let _ = STATE_DUMP_DIR.set(dir.clone());
493
494    let mut meta = String::new();
495    meta.push_str(&format!(
496        "reason: {reason}\ncurrent replay position: {}\n",
497        replay_position()
498    ));
499    if let Some(context) = context {
500        meta.push_str(&format!("context: {context:?}\n"));
501    }
502
503    if let Ok(ring) = STATE_RING.lock() {
504        meta.push_str("ring entries (oldest first):\n");
505        for (i, snap) in ring.iter().enumerate() {
506            meta.push_str(&format!(
507                "  state_{i:02}: pos={} op={}\n",
508                snap.pos, snap.op
509            ));
510        }
511    }
512
513    if let Some(current) = current {
514        let path = dir.join("current.json");
515        if let Err(e) = current.save_state(&path) {
516            eprintln!("state dump: could not write {}: {e:?}", path.display());
517        }
518        meta.push_str(&format!(
519            "current.json: current broken state (pos {})\n",
520            replay_position()
521        ));
522    }
523
524    if let Ok(ring) = STATE_RING.lock() {
525        for (i, snap) in ring.iter().enumerate() {
526            let path = dir.join(format!("state_{i:02}_pos_{:06}_{}.json", snap.pos, snap.op));
527            if let Err(e) = snap.mesh.save_state(&path) {
528                eprintln!("state dump: could not write {}: {e:?}", path.display());
529            }
530        }
531    }
532
533    if let Err(e) = std::fs::write(dir.join("meta.txt"), meta) {
534        eprintln!("state dump: could not write meta.txt: {e:?}");
535    }
536    eprintln!("state history dumped to {}", dir.display());
537}
538
539#[cfg(feature = "rerun")]
540lazy_static::lazy_static! {
541    pub static ref RR: rerun::RecordingStream = rerun::RecordingStreamBuilder::new("mesh_graph").spawn().unwrap();
542}
543
544/// Halfedge data structure for representing triangle meshes.
545///
546/// Please see the [crate documentation](crate) for more information.
547#[derive(Clone, Default)]
548#[cfg_attr(feature = "bevy", derive(bevy::prelude::Component))]
549#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
550#[cfg_attr(
551    feature = "serde",
552    serde(from = "crate::serialize::MeshGraphIntermediate")
553)]
554pub struct MeshGraph {
555    /// Acceleration structure for fast spatial queries. Uses parry3d's Bvh to implement some of parry3d's spatial queries.
556    #[cfg_attr(feature = "serde", serde(skip))]
557    pub bvh: Bvh,
558    /// Used in conjunction with the BVH to accelerate spatial queries.
559    #[cfg_attr(feature = "serde", serde(skip))]
560    pub bvh_workspace: BvhWorkspace,
561    /// Used to map indices stored in the BVH to face IDs.
562    #[cfg_attr(feature = "serde", serde(skip))]
563    pub index_to_face_id: HashMap<u32, FaceId>,
564    /// Used to compute the next index for a new face
565    #[cfg_attr(feature = "serde", serde(skip))]
566    pub next_index: u32,
567
568    /// Maps vertex IDs to their corresponding graph node
569    pub vertices: SlotMap<VertexId, Vertex>,
570    /// Maps halfedge IDs to their corresponding graph node
571    pub halfedges: SlotMap<HalfedgeId, Halfedge>,
572    /// Maps face IDs to their corresponding graph node
573    pub faces: SlotMap<FaceId, Face>,
574
575    /// Maps vertex IDs to their corresponding positions
576    pub positions: SecondaryMap<VertexId, Vec3>,
577    /// Maps vertex IDs to their corresponding normals
578    pub vertex_normals: Option<SecondaryMap<VertexId, Vec3>>,
579
580    /// Maps vertex IDs to their corresponding outgoing halfedges (not in any particular order)
581    #[cfg_attr(feature = "serde", serde(skip))]
582    pub outgoing_halfedges: SecondaryMap<VertexId, Vec<HalfedgeId>>,
583}
584
585impl MeshGraph {
586    /// Create a new empty mesh graph
587    #[inline]
588    pub fn new() -> Self {
589        Self::default()
590    }
591
592    /// Create a triangle mesh graph from vertex positions.
593    /// Every three positions represent a triangle.
594    ///
595    /// Vertices with the same position are merged into a single vertex.
596    pub fn triangles(vertex_positions: &[Vec3]) -> Option<Self> {
597        if !vertex_positions.len().is_multiple_of(3) {
598            return None;
599        }
600
601        // Create a map to track unique vertices
602        let mut unique_positions: Vec<Vec3> = Vec::with_capacity(vertex_positions.len() / 3);
603        let mut face_indices = Vec::with_capacity(vertex_positions.len());
604
605        for vertex_pos in vertex_positions {
606            // Check if we've seen this position before using a fuzzy float comparison
607            let mut idx = None;
608            for (j, pos) in unique_positions.iter().enumerate() {
609                const EPSILON: f32 = 1e-5;
610
611                if pos.distance_squared(*vertex_pos) < EPSILON {
612                    idx = Some(j);
613                    break;
614                }
615            }
616
617            // Use the existing index or add a new vertex
618            let vertex_idx = if let Some(idx) = idx {
619                idx
620            } else {
621                let new_idx = unique_positions.len();
622                unique_positions.push(*vertex_pos);
623
624                #[cfg(feature = "rerun")]
625                RR.log(
626                    "meshgraph/construct/vertices",
627                    &rerun::Points3D::new(unique_positions.iter().map(crate::utils::vec3_array)),
628                )
629                .unwrap();
630
631                new_idx
632            };
633
634            // Add to face indices
635            face_indices.push(vertex_idx);
636        }
637
638        // Use indexed_triangles to create the mesh
639        Some(Self::indexed_triangles(&unique_positions, &face_indices))
640    }
641
642    /// Create a triangle mesh graph from vertex positions, face indices,
643    /// and a custom vertex attribute.
644    #[instrument]
645    pub fn indexed_triangles_with_custom_attribute<T>(
646        vertex_positions: &[Vec3],
647        face_indices: &[usize],
648        custom_attribute: &[T],
649    ) -> (Self, SecondaryMap<VertexId, T>)
650    where
651        T: Clone + std::fmt::Debug,
652    {
653        let (mesh_graph, vertex_ids) =
654            Self::indexed_triangles_and_vertex_ids(vertex_positions, face_indices);
655
656        let mut custom_attribute_map = SecondaryMap::with_capacity(custom_attribute.len());
657        for (attr, vertex_id) in custom_attribute.iter().zip(vertex_ids) {
658            custom_attribute_map.insert(vertex_id, attr.clone());
659        }
660
661        (mesh_graph, custom_attribute_map)
662    }
663
664    /// Create a triangle mesh graph from vertex positions and face indices.
665    /// Every chunk of three indices represents a triangle.
666    #[inline]
667    pub fn indexed_triangles(vertex_positions: &[Vec3], face_indices: &[usize]) -> Self {
668        Self::indexed_triangles_and_vertex_ids(vertex_positions, face_indices).0
669    }
670
671    /// Create a triangle mesh graph from vertex positions and face indices,
672    /// returning the graph and a list of vertex IDs in the same order as `vertex_positions`.
673    #[instrument]
674    pub fn indexed_triangles_and_vertex_ids(
675        vertex_positions: &[Vec3],
676        face_indices: &[usize],
677    ) -> (Self, Vec<VertexId>) {
678        let mut mesh_graph = Self {
679            bvh: Bvh::new(),
680            bvh_workspace: BvhWorkspace::default(),
681            index_to_face_id: HashMap::with_capacity(face_indices.len() / 3),
682            next_index: 0,
683
684            vertices: SlotMap::with_capacity_and_key(vertex_positions.len()),
685            halfedges: SlotMap::with_capacity_and_key(face_indices.len()),
686            faces: SlotMap::with_capacity_and_key(face_indices.len() / 3),
687
688            positions: SecondaryMap::with_capacity(vertex_positions.len()),
689            vertex_normals: None,
690            outgoing_halfedges: SecondaryMap::with_capacity(vertex_positions.len()),
691        };
692
693        let mut vertex_ids = Vec::with_capacity(vertex_positions.len());
694
695        for pos in vertex_positions {
696            vertex_ids.push(mesh_graph.add_vertex(*pos));
697        }
698
699        for chunk in face_indices.as_chunks::<3>().0 {
700            let a = vertex_ids[chunk[0]];
701            let b = vertex_ids[chunk[1]];
702            let c = vertex_ids[chunk[2]];
703
704            if a == b || b == c || c == a {
705                #[cfg(feature = "rerun")]
706                RR.log(
707                    "meshgraph/construct/zero_face",
708                    &rerun::Points3D::new(
709                        [
710                            mesh_graph.positions[a],
711                            mesh_graph.positions[b],
712                            mesh_graph.positions[c],
713                        ]
714                        .iter()
715                        .map(crate::utils::vec3_array),
716                    ),
717                )
718                .unwrap();
719
720                continue;
721            }
722
723            // Vertices have already been added to the mesh graph, so we can safely use `unwrap()` here
724            let he_a_id = mesh_graph.add_or_get_edge(a, b).unwrap().start_to_end_he_id;
725            let he_b_id = mesh_graph.add_or_get_edge(b, c).unwrap().start_to_end_he_id;
726            let he_c_id = mesh_graph.add_or_get_edge(c, a).unwrap().start_to_end_he_id;
727
728            let _face_id = mesh_graph.add_face(he_a_id, he_b_id, he_c_id);
729        }
730
731        mesh_graph.make_all_outgoing_halfedges_boundary_if_possible();
732        mesh_graph.rebuild_bvh();
733
734        (mesh_graph, vertex_ids)
735    }
736
737    /// Pairs a surviving halfedge with a freshly created boundary halfedge so a
738    /// halfedge is never left twinless after its partner is removed or re-paired
739    /// elsewhere (every halfedge must have a twin in a valid state; boundary edges
740    /// are represented as a pair of a face member and a detached half).
741    ///
742    /// `survivor_start_v` is the survivor's start vertex (derived by the caller,
743    /// since the survivor's own twin may already be gone). The new halfedge claims
744    /// no face and is registered in `outgoing_halfedges` under its start vertex
745    /// (= the survivor's end vertex). Returns the new boundary halfedge.
746    #[instrument(skip(self))]
747    fn pair_with_fresh_boundary_half(
748        &mut self,
749        survivor_id: HalfedgeId,
750        survivor_start_v: VertexId,
751    ) -> Option<HalfedgeId> {
752        let survivor = self
753            .halfedges
754            .get(survivor_id)
755            .or_else(error_none!("survivor he not found"))?;
756        let survivor_end = survivor.end_vertex;
757        // `add_halfedge` already registers `boundary_id` in `outgoing_halfedges`
758        // under its start vertex (= `survivor_end`), so do not push it again here.
759        let boundary_id = self.add_halfedge(survivor_end, survivor_start_v)?;
760        // just checked above that the survivor exists
761        self.halfedges[survivor_id].twin = Some(boundary_id);
762        // just added above
763        self.halfedges[boundary_id].twin = Some(survivor_id);
764
765        #[cfg(feature = "instrumentation")]
766        crate::record_op_trace!(
767            "fresh boundary {boundary_id:?} ({survivor_end:?}->{survivor_start_v:?}) paired with survivor {survivor_id:?}"
768        );
769
770        Some(boundary_id)
771    }
772
773    /// Repairs a vertex's `outgoing_halfedge` seed when it points at a halfedge that
774    /// no longer exists. Ring traversals (`one_ring`, faces, ...) start from this seed,
775    /// so a dead seed makes them yield removed ids. Replaces it with any live outgoing
776    /// halfedge of the vertex, or `None` if the vertex has become isolated. A live seed
777    /// is left untouched to keep ring iteration order stable in the common case.
778    fn reseed_outgoing_if_dead(&mut self, vertex_id: VertexId) {
779        let Some(vertex) = self.vertices.get(vertex_id) else {
780            return;
781        };
782        if vertex
783            .outgoing_halfedge
784            .is_some_and(|he| self.halfedges.contains_key(he))
785        {
786            return;
787        }
788        let new_seed = self.outgoing_halfedges.get(vertex_id).and_then(|list| {
789            list.iter()
790                .copied()
791                .find(|he| self.halfedges.contains_key(*he))
792        });
793        if let Some(v) = self.vertices.get_mut(vertex_id) {
794            v.outgoing_halfedge = new_seed;
795        }
796    }
797
798    /// Debug probe (`instrumentation` feature): reports once per process when a
799    /// halfedge removal takes a halfedge that still
800    /// belongs to a *live* face — a face that is not being dismantled by the same call
801    /// (`op` is not `remove_face_tail` / `remove_halfedge_face`) — and whose other
802    /// members survive. Such a removal breaks the face chain and corrupts the mesh.
803    ///
804    /// Pure instrumentation: it never mutates the mesh. Removal sites used to funnel
805    /// through `clear_twins_to`, which nulled the surviving partner's `twin` before
806    /// the `halfedges.remove(...)`. Re-pairing is now done locally at each removal
807    /// site, so no `.twin = None` write exists in the codebase anymore: every surviving
808    /// halfedge is re-paired (fresh boundary half, partner swap) or removed in the same
809    /// batch as its partner before its operation terminates.
810    #[cfg(feature = "instrumentation")]
811    pub(crate) fn probe_live_face_removal(&self, removed_ids: &[HalfedgeId], op: &str) {
812        if removed_ids.is_empty() {
813            return;
814        }
815
816        static REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
817        if !matches!(op, "remove_face_tail" | "remove_halfedge_face") {
818            for id in removed_ids {
819                if let Some(he) = self.halfedges.get(*id)
820                    && let Some(face_id) = he.face
821                    && self.faces.contains_key(face_id)
822                {
823                    let other_members: Vec<HalfedgeId> = self
824                        .halfedges
825                        .iter()
826                        .filter(|(h_id, h)| {
827                            h.face == Some(face_id) && *h_id != *id && !removed_ids.contains(h_id)
828                        })
829                        .map(|(h_id, _)| h_id)
830                        .take(4)
831                        .collect();
832                    if !other_members.is_empty() && REPORTED.set(()).is_ok() {
833                        mark_integrity_violation();
834                        eprintln!(
835                            "REMOVING LIVE-FACE MEMBER {id:?} of face {face_id:?} (surviving members {other_members:?})"
836                        );
837                        eprintln!("{}", std::backtrace::Backtrace::force_capture());
838                        state_history_dump("live_face_member_removal", Some(self), Some(&id));
839                    }
840                }
841            }
842        }
843    }
844
845    /// Debug probe (`instrumentation` feature): reports once per process the first
846    /// operation that terminates with a broken
847    /// face chain — a live halfedge whose `next` is `None`, references a removed
848    /// halfedge, or references a halfedge claimed by a different (or no) face.
849    ///
850    /// Such a chain is what later yields dead ids into `one_ring` walks and
851    /// subdivide/collapse bookkeeping (which panic on the stale SlotMap key), long
852    /// after the op that actually broke the chain. Checking at op boundaries catches
853    /// the writer instead of the walker.
854    ///
855    /// Beyond the chains, the probe also verifies the two other bookkeeping
856    /// invariants against their rebuild ground truth:
857    ///
858    /// - **Twin invariant** (every live halfedge, incl. boundary halves, must have a
859    ///   live mutual twin at op end) and
860    /// - **outgoing lists**: `outgoing_halfedges[V]` must contain exactly the twins
861    ///   of the live halfedges ending at `V` (what [`rebuild_outgoing_halfedges`]
862    ///   produces). Missing/extra ids are corruption.
863    ///
864    /// Both are state-dumped on first violation. List *order* and per-vertex seed
865    /// deviations have legitimate alternatives (ops may re-order lists; the rebuild
866    /// keeps live seeds), so those are reported once without consuming the dump.
867    ///
868    /// Pure instrumentation: it never mutates the mesh. Returns `true` when the
869    /// mesh passed all checks; on the first corruption the state-history ring is
870    /// dumped to disk (see [`state_history_dump`]).
871    #[cfg(feature = "instrumentation")]
872    pub(crate) fn probe_chain_integrity(&self, op: &str) -> bool {
873        static REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
874
875        let mut violations: Vec<(HalfedgeId, &'static str)> = Vec::new();
876        let mut dead_face_siblings: Vec<FaceId> = Vec::new();
877        for (he_id, he) in &self.halfedges {
878            let Some(face_id) = he.face else {
879                continue;
880            };
881
882            let verdict = if !self.faces.contains_key(face_id) {
883                if !dead_face_siblings.contains(&face_id) {
884                    dead_face_siblings.push(face_id);
885                }
886                Some("member of a removed face")
887            } else {
888                match he.next {
889                    None => Some("member of a live face with next=None"),
890                    Some(next_id) => match self.halfedges.get(next_id) {
891                        None => Some("next references a removed halfedge"),
892                        Some(next_he) if next_he.face != Some(face_id) => {
893                            Some("next references a halfedge of another/no face")
894                        }
895                        Some(_) => None,
896                    },
897                }
898            };
899
900            if let Some(reason) = verdict {
901                violations.push((he_id, reason));
902                if violations.len() >= 12 {
903                    break;
904                }
905            }
906        }
907
908        // Family-vs-chain fork check: every halfedge that claims a live face (its
909        // member family) must also be reachable through the face's `next` chain.
910        // A face whose family contains halfedges outside its chain is the face-steal
911        // residue: `add_face` re-filed the stray while the face's own chain re-uses
912        // (or lost) it elsewhere.
913        let mut fork: Option<(FaceId, Vec<HalfedgeId>, Vec<HalfedgeId>)> = None;
914        let mut family_by_face: hashbrown::HashMap<FaceId, Vec<HalfedgeId>> =
915            hashbrown::HashMap::new();
916        for (h_id, h) in &self.halfedges {
917            if let Some(f) = h.face {
918                family_by_face.entry(f).or_default().push(h_id);
919            }
920        }
921        for (face_id, face) in &self.faces {
922            let Some(family) = family_by_face.get(&face_id) else {
923                continue;
924            };
925            if family.len() < 3 {
926                // Not a proper triangle face; the other verdicts cover the broken cases.
927                continue;
928            }
929            let mut chain = Vec::with_capacity(family.len());
930            let mut cur = Some(face.halfedge);
931            let mut steps = 0;
932            while let Some(he_id) = cur {
933                if !self.halfedges.contains_key(he_id) || chain.len() > family.len() + 2 {
934                    break;
935                }
936                if chain.contains(&he_id) {
937                    break;
938                }
939                chain.push(he_id);
940                cur = self.halfedges[he_id].next;
941                steps += 1;
942                if steps > 16 {
943                    break;
944                }
945            }
946            if chain.len() != family.len() || family.iter().any(|h_id| !chain.contains(h_id)) {
947                fork = Some((face_id, family.clone(), chain));
948                break;
949            }
950        }
951
952        // --- Twin invariant + outgoing-halfedge ground truth ---
953        // `rebuild_outgoing_halfedges` is the definite ground truth for the
954        // per-vertex lists: `outgoing_halfedges[V]` must contain exactly the twins
955        // of the live halfedges ending at V, in halfedge-iteration order. This
956        // sweep checks every live halfedge (incl. boundary halves, which the chain
957        // check above skips): a halfedge without a live mutual twin, or a list with
958        // missing/extra ids at op end, is corruption.
959        let fmt_ids = |ids: &[HalfedgeId]| -> String {
960            if ids.len() <= 8 {
961                format!("{ids:?}")
962            } else {
963                format!("{:?}... ({} ids)", &ids[..8], ids.len())
964            }
965        };
966        let mut twin_problems: Vec<String> = Vec::new();
967        let mut membership_problems: Vec<String> = Vec::new();
968        // Order deviations are expected to be pervasive (ops re-order lists), so
969        // only a counter plus the first sample is kept.
970        let mut order_deviation_count: usize = 0;
971        let mut first_order_sample: Option<String> = None;
972        // Reusable scratch buffers: the probe runs at every op end (~1600x per
973        // log replay), so allocations are retained across calls instead of
974        // churning the allocator (which showed up as minutes of sys time).
975        struct OutScratch {
976            expected: hashbrown::HashMap<VertexId, Vec<HalfedgeId>>,
977            counts: hashbrown::HashMap<HalfedgeId, usize>,
978        }
979        static OUT_SCRATCH: std::sync::Mutex<Option<OutScratch>> = std::sync::Mutex::new(None);
980        let mut scratch = OUT_SCRATCH.lock().unwrap();
981        let scratch = scratch.get_or_insert_with(|| OutScratch {
982            expected: hashbrown::HashMap::new(),
983            counts: hashbrown::HashMap::new(),
984        });
985        for list in scratch.expected.values_mut() {
986            list.clear();
987        }
988        scratch.expected.clear();
989        scratch.counts.clear();
990
991        for (he_id, he) in &self.halfedges {
992            match he.twin {
993                None => twin_problems.push(format!("halfedge {he_id:?} has twin=None")),
994                Some(twin_id) => {
995                    if !self.halfedges.contains_key(twin_id) {
996                        twin_problems.push(format!(
997                            "halfedge {he_id:?} has twin {twin_id:?} which is removed"
998                        ));
999                    } else if self.halfedges[twin_id].twin != Some(he_id) {
1000                        twin_problems.push(format!(
1001                            "halfedge {he_id:?} has twin {twin_id:?} which does not point back"
1002                        ));
1003                    }
1004                }
1005            }
1006            if let Some(twin_id) = he.twin {
1007                scratch
1008                    .expected
1009                    .entry(he.end_vertex)
1010                    .or_default()
1011                    .push(twin_id);
1012            }
1013        }
1014
1015        for (v_id, expected) in scratch.expected.iter() {
1016            let actual: &[HalfedgeId] = self
1017                .outgoing_halfedges
1018                .get(*v_id)
1019                .map(Vec::as_slice)
1020                .unwrap_or(&[]);
1021            // Multiset difference in O(|actual| + |expected|) via a count map.
1022            scratch.counts.clear();
1023            for &a in actual {
1024                *scratch.counts.entry(a).or_default() += 1;
1025            }
1026            let mut missing: Vec<HalfedgeId> = Vec::new();
1027            for &exp in expected {
1028                match scratch.counts.get_mut(&exp) {
1029                    Some(c) if *c > 0 => *c -= 1,
1030                    _ => missing.push(exp),
1031                }
1032            }
1033            let mut extra: Vec<HalfedgeId> = Vec::new();
1034            for &a in actual {
1035                if scratch.counts.get(&a) != Some(&0) {
1036                    extra.push(a);
1037                }
1038            }
1039            if !missing.is_empty() || !extra.is_empty() {
1040                let mut msg = format!("vertex {v_id:?}: outgoing deviates from ground truth");
1041                if !missing.is_empty() {
1042                    msg += &format!(", missing {}", fmt_ids(&missing));
1043                }
1044                if !extra.is_empty() {
1045                    msg += &format!(", extra {}", fmt_ids(&extra));
1046                }
1047                membership_problems.push(msg);
1048            } else if actual != expected.as_slice() {
1049                order_deviation_count += 1;
1050                if first_order_sample.is_none() {
1051                    first_order_sample = Some(format!(
1052                        "vertex {v_id:?}: outgoing order differs from rebuild order"
1053                    ));
1054                }
1055            }
1056        }
1057        // Vertices holding list entries although no live halfedge ends at them.
1058        for (v_id, actual) in &self.outgoing_halfedges {
1059            if !scratch.expected.contains_key(&v_id) && !actual.is_empty() {
1060                membership_problems.push(format!(
1061                    "vertex {v_id:?}: outgoing {} but no live halfedge ends at it",
1062                    fmt_ids(actual)
1063                ));
1064            }
1065        }
1066
1067        // Per-vertex seed normalization, simulated from `rebuild_outgoing_halfedges`:
1068        // a live seed is kept, a dead seed is replaced with the first list entry.
1069        let mut seed_deviations: Vec<String> = Vec::new();
1070        for (v_id, vertex) in &self.vertices {
1071            let stored = vertex.outgoing_halfedge;
1072            let rebuilt_seed = stored
1073                .filter(|he| self.halfedges.contains_key(*he))
1074                .or_else(|| scratch.expected.get(&v_id).and_then(|l| l.first().copied()));
1075            if stored != rebuilt_seed {
1076                seed_deviations.push(format!(
1077                    "vertex {v_id:?}: seed {stored:?} != rebuilt {rebuilt_seed:?}"
1078                ));
1079            }
1080        }
1081
1082        let clean =
1083            violations.is_empty() && twin_problems.is_empty() && membership_problems.is_empty();
1084
1085        if !clean && REPORTED.set(()).is_ok() {
1086            mark_integrity_violation();
1087            eprintln!(
1088                "CHAIN CORRUPTION detected at end of op '{op}' ({} violations shown):",
1089                violations.len()
1090            );
1091            for (he_id, reason) in violations {
1092                let detail = self.halfedges.get(he_id).map(|he| {
1093                    format!(
1094                        "face={:?} next={:?} twin={:?} end={:?}",
1095                        he.face, he.next, he.twin, he.end_vertex
1096                    )
1097                });
1098                eprintln!("  halfedge {he_id:?}: {reason}; {detail:?}");
1099                // Neighborhood dump: the halfedge's next target, twin, and the
1100                // start vertex's outgoing star, so the ghost's surroundings are
1101                // visible (which live faces/edges it connects to).
1102                if let Some(he) = self.halfedges.get(he_id) {
1103                    if let Some(next_id) = he.next
1104                        && let Some(next_he) = self.halfedges.get(next_id)
1105                    {
1106                        eprintln!(
1107                            "    next {next_id:?}: face={:?} next={:?} twin={:?} end={:?}",
1108                            next_he.face, next_he.next, next_he.twin, next_he.end_vertex
1109                        );
1110                    }
1111                    if let Some(twin_id) = he.twin
1112                        && let Some(twin_he) = self.halfedges.get(twin_id)
1113                    {
1114                        eprintln!(
1115                            "    twin {twin_id:?}: face={:?} next={:?} twin={:?} end={:?}",
1116                            twin_he.face, twin_he.next, twin_he.twin, twin_he.end_vertex
1117                        );
1118                    }
1119                    if let Some(start_v) = he.start_vertex(self) {
1120                        let out: Vec<HalfedgeId> = self
1121                            .outgoing_halfedges
1122                            .get(start_v)
1123                            .map(|l| l.iter().copied().take(6).collect())
1124                            .unwrap_or_default();
1125                        let out_desc: Vec<String> = out
1126                            .iter()
1127                            .filter_map(|id| {
1128                                self.halfedges
1129                                    .get(*id)
1130                                    .map(|h| format!("{id:?}(face={:?},next={:?})", h.face, h.next))
1131                            })
1132                            .collect();
1133                        eprintln!("    start vertex {start_v:?} outgoing: {out_desc:?}");
1134                    }
1135                }
1136            }
1137            // For halfedges that claim a removed face, print the other live halfedges
1138            // claiming the same dead face (the family that escaped the removal).
1139            for dead_face_id in &dead_face_siblings {
1140                let family: Vec<HalfedgeId> = self
1141                    .halfedges
1142                    .iter()
1143                    .filter(|(_, h)| h.face == Some(*dead_face_id))
1144                    .map(|(h_id, _)| h_id)
1145                    .collect();
1146                eprintln!("  halfedges claiming removed face {dead_face_id:?}: {family:?}");
1147            }
1148            eprintln!("{}", std::backtrace::Backtrace::force_capture());
1149            eprintln!("recent face deaths (oldest first):");
1150            dump_face_death_ledger();
1151            if let Some((fork_face, fork_family, fork_chain)) = fork {
1152                eprintln!("family-vs-chain for face {fork_face:?}:");
1153                eprintln!("  chain  (walked): {fork_chain:?}");
1154                eprintln!("  family (face field): {fork_family:?}");
1155            }
1156            for problem in &twin_problems {
1157                eprintln!("TWIN: {problem}");
1158            }
1159            for problem in membership_problems.iter().take(12) {
1160                eprintln!("OUTGOING: {problem}");
1161            }
1162            if let Ok(trace) = OP_TRACE.lock() {
1163                eprintln!("op trace (oldest first):");
1164                for event in trace.iter() {
1165                    eprintln!("  {event}");
1166                }
1167            }
1168            state_history_dump("chain_integrity", Some(self), Some(&op));
1169        }
1170
1171        // Hole-delta detector (`MESH_GRAPH_HOLE_CHECK=1`): each probed op
1172        // (collapse/subdivide/merge/...)
1173        // must leave the boundary edge set exactly as it was at its own entry (see
1174        // [`crate::probe_chain_begin`]).
1175        //
1176        // Two levels, split by the op's own entry boundary: an op that starts on a
1177        // closed region (weld runs) must stay closed — any boundary change marks an
1178        // integrity violation. An op that starts next to a punch-hole rim may
1179        // legitimately swap rim edges (cleanup collapses can consume a rim edge and
1180        // re-pair its twin with the new fan edge, a 1:1 boundary swap) — that is
1181        // reported informationally only, so the integrity flag can't be contaminated
1182        // by expected rim evolution; a real defect there would additionally trip the
1183        // chain/twin probes. `remove_face`-family ops are not probed, so the
1184        // intentional punch itself is never blamed. Reported once per process,
1185        // separately from the chain corruption report so the two signals don't mask
1186        // each other.
1187        if hole_check_enabled() {
1188            static HOLE_REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1189            static RIM_REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1190            let current = self.boundary_edge_set();
1191            let begin = OP_BOUNDARY.with(|b| b.borrow().clone());
1192            if let Some(begin) = begin {
1193                let added: Vec<(HalfedgeId, HalfedgeId)> =
1194                    current.difference(&begin).copied().collect();
1195                let removed: Vec<(HalfedgeId, HalfedgeId)> =
1196                    begin.difference(&current).copied().collect();
1197                if !added.is_empty() || !removed.is_empty() {
1198                    let boundary_count =
1199                        self.halfedges.values().filter(|h| h.face.is_none()).count();
1200                    if begin.is_empty() {
1201                        // The op started on a closed region: any boundary change is a
1202                        // defect.
1203                        if HOLE_REPORTED.set(()).is_ok() {
1204                            mark_integrity_violation();
1205                            eprintln!(
1206                                "HOLE DELTA: op '{op}' changed the boundary edge set of a closed region (now {boundary_count} boundary halfedges):"
1207                            );
1208                            dump_boundary_delta(&added, &removed);
1209                            eprintln!("{}", std::backtrace::Backtrace::force_capture());
1210                            if let Ok(trace) = OP_TRACE.lock() {
1211                                eprintln!("op trace (oldest first):");
1212                                for event in trace.iter() {
1213                                    eprintln!("  {event}");
1214                                }
1215                            }
1216                            state_history_dump("hole", Some(self), Some(&op));
1217                        }
1218                    } else if RIM_REPORTED.set(()).is_ok() {
1219                        // The op started next to a punch rim: boundary swaps are
1220                        // expected during punch cleanup; only informational.
1221                        eprintln!(
1222                            "RIM DELTA: op '{op}' changed the boundary edge set of an open region (now {boundary_count} boundary halfedges) — expected during punch cleanup:"
1223                        );
1224                        dump_boundary_delta(&added, &removed);
1225                    }
1226                }
1227            }
1228        }
1229
1230        // Possibly-legitimate alternatives to the rebuild ground truth (list order,
1231        // seed choice): reported once per process with counts, without consuming
1232        // the once-per-process corruption dump above.
1233        if order_deviation_count > 0 {
1234            static ORDER_REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1235            if ORDER_REPORTED.set(()).is_ok() {
1236                eprintln!(
1237                    "OUTGOING ORDER deviates from rebuild order at {order_deviation_count} vertices (first: {})",
1238                    first_order_sample.as_deref().unwrap_or("")
1239                );
1240            }
1241        }
1242        if !seed_deviations.is_empty() {
1243            static SEED_REPORTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1244            if SEED_REPORTED.set(()).is_ok() {
1245                eprintln!(
1246                    "SEED deviates from rebuild at op '{op}' at {} vertices (first: {})",
1247                    seed_deviations.len(),
1248                    seed_deviations[0]
1249                );
1250            }
1251        }
1252
1253        clean
1254    }
1255
1256    /// Undirected boundary edge set: the normalized `(min, max)` pairs of the
1257    /// twin couple of every live halfedge with `face = None`. Two meshes with the
1258    /// same open edges produce equal sets regardless of fan order, so a
1259    /// before/after comparison detects boundary changes without being sensitive
1260    /// to halfedge iteration order. Only used by the hole-delta probe.
1261    #[cfg(feature = "instrumentation")]
1262    fn boundary_edge_set(&self) -> hashbrown::HashSet<(HalfedgeId, HalfedgeId)> {
1263        let mut edges = hashbrown::HashSet::new();
1264        for (he_id, he) in &self.halfedges {
1265            if he.face.is_none()
1266                && let Some(twin_id) = he.twin
1267            {
1268                edges.insert(if twin_id < he_id {
1269                    (twin_id, he_id)
1270                } else {
1271                    (he_id, twin_id)
1272                });
1273            }
1274        }
1275        edges
1276    }
1277
1278    /// Ground-truth rebuild of a single vertex's outgoing list: the halfedges
1279    /// whose twins end at the vertex (the inverse of `rebuild_outgoing_halfedges`,
1280    /// which derives the lists from the halfedge iteration). Unlike a seed-based
1281    /// ring walk, this is immune to a dead or stale seed and to temporarily
1282    /// detached (face-less) halfedges, so it never shrinks a vertex's list below
1283    /// its true star.
1284    ///
1285    /// The seed is refreshed with `rebuild_outgoing_halfedges` semantics: a live
1286    /// seed is kept, otherwise the first list entry is used.
1287    pub fn rebuild_vertex_outgoing_list(&mut self, vertex_id: VertexId) {
1288        let mut list: Vec<HalfedgeId> = Vec::new();
1289        for (_, he) in &self.halfedges {
1290            if he.end_vertex == vertex_id
1291                && let Some(twin_id) = he.twin
1292                && self.halfedges.contains_key(twin_id)
1293            {
1294                list.push(twin_id);
1295            }
1296        }
1297
1298        if let Some(entry) = self.outgoing_halfedges.get_mut(vertex_id) {
1299            *entry = list;
1300        }
1301
1302        if let Some(vertex) = self.vertices.get_mut(vertex_id)
1303            && !vertex
1304                .outgoing_halfedge
1305                .is_some_and(|he| self.halfedges.contains_key(he))
1306        {
1307            vertex.outgoing_halfedge = self
1308                .outgoing_halfedges
1309                .get(vertex_id)
1310                .and_then(|l| l.first().copied());
1311        }
1312    }
1313
1314    /// Computes the vertex normal from neighboring faces
1315    pub fn compute_vertex_normal(&mut self, vertex_id: VertexId) {
1316        if self.vertex_normals.is_none() {
1317            return;
1318        }
1319
1320        let vertex = unwrap_or_return!(self.vertices.get(vertex_id), "Vertex not found");
1321
1322        let mut normal = Vec3::ZERO;
1323
1324        for face_id in vertex.faces(self) {
1325            let face = unwrap_or_return!(self.faces.get(face_id), "Face not found");
1326            let face_normal = face.normal(self);
1327            normal += unwrap_or_return!(face_normal, "Face normal not found");
1328        }
1329
1330        self.vertex_normals
1331            .as_mut()
1332            .unwrap()
1333            .insert(vertex_id, normal.try_normalize().unwrap_or(Vec3::ZERO));
1334    }
1335
1336    /// Computes the vertex normals by averaging over the computed face normals
1337    #[instrument(skip(self))]
1338    pub fn compute_vertex_normals(&mut self) {
1339        let mut normals = SecondaryMap::with_capacity(self.vertices.len());
1340
1341        for face in self.faces.values() {
1342            let Some(&he_a) = self.halfedges.get(face.halfedge) else {
1343                error!("Halfedge not found");
1344                continue;
1345            };
1346
1347            let Some(he_b_id) = he_a.next else {
1348                error!("Halfedge has no next halfedge");
1349                continue;
1350            };
1351            let Some(he_b) = self.halfedges.get(he_b_id) else {
1352                error!("Next halfedge not found");
1353                continue;
1354            };
1355
1356            let a = match he_a.start_vertex(self) {
1357                Some(v) => v,
1358                None => {
1359                    error!("Start vertex not found");
1360                    continue;
1361                }
1362            };
1363            let b = he_a.end_vertex;
1364            let c = he_b.end_vertex;
1365
1366            let (Some(pos_a), Some(pos_b), Some(pos_c)) = (
1367                self.positions.get(a),
1368                self.positions.get(b),
1369                self.positions.get(c),
1370            ) else {
1371                continue;
1372            };
1373
1374            let diff_a = pos_c - pos_a;
1375            let diff_b = pos_c - pos_b;
1376
1377            // TODO : normalizing necessary here?
1378            let face_normal = diff_a.cross(diff_b);
1379
1380            for v_id in [a, b, c] {
1381                let Some(entry) = normals.entry(v_id) else {
1382                    continue;
1383                };
1384                *entry.or_default() += face_normal;
1385            }
1386        }
1387
1388        self.vertex_normals = Some(normals);
1389        self.normalize_vertex_normals();
1390    }
1391
1392    /// Ensures that the vertex normals are all normalized
1393    pub fn normalize_vertex_normals(&mut self) {
1394        if let Some(normals) = &mut self.vertex_normals {
1395            for normal in normals.values_mut() {
1396                *normal = normal.normalize_or_zero();
1397            }
1398        }
1399    }
1400
1401    /// Calls the `optimize_incremental` method of the BVH.
1402    #[inline]
1403    pub fn optimize_bvh_incremental(&mut self) {
1404        self.bvh.optimize_incremental(&mut self.bvh_workspace);
1405    }
1406
1407    /// Recomputes the bounding boxes of the BVH. This is necessary when the mesh is modified.
1408    #[inline]
1409    pub fn refit_bvh(&mut self) {
1410        self.bvh.refit(&mut self.bvh_workspace);
1411    }
1412
1413    /// Rebuilds the BVH from scratch
1414    #[inline]
1415    pub fn rebuild_bvh(&mut self) {
1416        self.bvh = Bvh::new();
1417        self.bvh_workspace = BvhWorkspace::default();
1418
1419        for face in self.faces.values() {
1420            self.bvh
1421                .insert_or_update_partially(face.aabb(self), face.index, 0.0);
1422        }
1423        self.bvh
1424            .rebuild(&mut self.bvh_workspace, Default::default());
1425    }
1426
1427    #[instrument(skip_all)]
1428    /// Repairs the redundant `Halfedge::face` cache from the halfedge chains, which are
1429    /// the ground truth for face membership (just like `rebuild_outgoing_halfedges` is for
1430    /// the per-vertex lists). Stale `.face` pointers (e.g. after flap-removal twin
1431    /// re-pairs) make later operations subdivide the wrong faces and degenerate the mesh.
1432    /// Also clears `.face` on halfedges that are no longer reachable from any face's chain.
1433    ///
1434    /// O(F + H), meant to be called once per operation that rewires faces.
1435    pub fn repair_face_pointers(&mut self) {
1436        let face_ids: Vec<FaceId> = self.faces.keys().collect();
1437        let mut visited: hashbrown::HashMap<HalfedgeId, FaceId> = hashbrown::HashMap::new();
1438
1439        for face_id in face_ids {
1440            let Some(start_he) = self.faces.get(face_id).map(|f| f.halfedge) else {
1441                continue;
1442            };
1443
1444            let mut he_id = start_he;
1445            for _ in 0..32 {
1446                let Some(he) = self.halfedges.get_mut(he_id) else {
1447                    break;
1448                };
1449                he.face = Some(face_id);
1450                visited.insert(he_id, face_id);
1451
1452                let Some(next) = he.next else {
1453                    break;
1454                };
1455                if next == start_he {
1456                    break;
1457                }
1458                he_id = next;
1459            }
1460        }
1461
1462        // Halfedges that claim a face but are not reachable from that face's chain are
1463        // orphans left behind by re-links (e.g. flap twin re-pairs). Clear their stale
1464        // `.face` so they read as boundary, which all traversals handle.
1465        let orphan_ids: Vec<HalfedgeId> = self
1466            .halfedges
1467            .iter()
1468            .filter(|(he_id, he)| he.face.is_some() && !visited.contains_key(he_id))
1469            .map(|(he_id, _)| he_id)
1470            .collect();
1471
1472        for he_id in orphan_ids {
1473            if let Some(he) = self.halfedges.get_mut(he_id) {
1474                he.face = None;
1475            }
1476        }
1477    }
1478
1479    pub fn rebuild_outgoing_halfedges(&mut self) {
1480        self.outgoing_halfedges.clear();
1481
1482        // Keep an (empty) list entry for every live vertex: a live vertex without any
1483        // halfedges (e.g. a leftover isolated vertex after a cleanup) must still have a list
1484        // entry so lookups like `halfedge_from_to` answer "no edge" instead of failing with
1485        // "Start vertex not found".
1486        for vertex_id in self.vertices.keys() {
1487            self.outgoing_halfedges.insert(vertex_id, Vec::new());
1488        }
1489
1490        for halfedge in self.halfedges.values() {
1491            let Some(twin_id) = halfedge.twin else {
1492                error!("Halfedge has no twin");
1493                continue;
1494            };
1495
1496            let Some(entry) = self.outgoing_halfedges.entry(halfedge.end_vertex) else {
1497                error!("Vertex key invalid");
1498                continue;
1499            };
1500
1501            entry.or_default().push(twin_id);
1502        }
1503
1504        // Normalize the per-vertex seed pointers (`vertices[v].outgoing_halfedge`). Stale
1505        // seeds pointing at removed halfedges make ring traversals (`one_ring`, faces, ...)
1506        // yield dead ids, which panics callers that index them. Only replace the seed when
1507        // it is dead, to keep ring iteration order stable in the common case.
1508        for (v_id, vertex) in &mut self.vertices {
1509            let stored_seed = vertex.outgoing_halfedge;
1510            let live_seed = stored_seed.filter(|he| self.halfedges.contains_key(*he));
1511            vertex.outgoing_halfedge = live_seed.or_else(|| {
1512                self.outgoing_halfedges
1513                    .get(v_id)
1514                    .and_then(|list| list.first().copied())
1515            });
1516        }
1517    }
1518}
1519
1520#[cfg(all(test, feature = "instrumentation"))]
1521mod integrity_violation_tests {
1522    use super::{
1523        integrity_violation_reported, mark_integrity_violation, reset_integrity_violation,
1524    };
1525
1526    /// The flag must be per-thread and clearable, so one replay's violation is never
1527    /// attributed to another test running beside it under the default parallel harness.
1528    #[test]
1529    fn violation_flag_is_per_thread_and_resettable() {
1530        reset_integrity_violation();
1531        assert!(!integrity_violation_reported());
1532
1533        // A violation raised on another thread must not leak into this one.
1534        std::thread::spawn(|| {
1535            mark_integrity_violation();
1536            assert!(
1537                integrity_violation_reported(),
1538                "flag must set on its own thread"
1539            );
1540        })
1541        .join()
1542        .expect("probe thread panicked");
1543        assert!(
1544            !integrity_violation_reported(),
1545            "another thread's violation leaked into this thread"
1546        );
1547
1548        mark_integrity_violation();
1549        assert!(integrity_violation_reported());
1550
1551        reset_integrity_violation();
1552        assert!(!integrity_violation_reported(), "reset must clear the flag");
1553    }
1554}