axiolid_tetrahedralize/
delaunay.rs

1//! Incremental 3D Delaunay triangulation (Bowyer-Watson).
2//!
3//! # Structure
4//!
5//! The triangulation covers all of space: besides the finite tetrahedra it
6//! keeps one *infinite* cell per convex-hull face, joining that face to a
7//! symbolic vertex at infinity. Every cell then has exactly four neighbours,
8//! so insertion never special-cases the hull: a point outside it conflicts
9//! with the infinite cells over the faces it sees, and those are replaced
10//! like any other.
11//!
12//! A cell stores its vertices and, at index `i`, the neighbour across the
13//! face opposite vertex `i`. Finite cells are positively oriented
14//! ([`orientation`] > 0). An infinite cell is oriented as if its infinite
15//! vertex were a point beyond its hull face.
16//!
17//! # Insertion
18//!
19//! 1. Locate: a stochastic visibility walk from the last created cell steps
20//!    across any face the point lies strictly beyond, in random face order,
21//!    until no face separates them; it ends in the finite cell containing the
22//!    point (closed) or in the infinite cell of a hull face the point sees.
23//!    A point equal to a vertex of the closed cell is a duplicate.
24//! 2. Conflict region: every cell whose perturbed circumsphere contains the
25//!    point, grown by breadth-first search from the located cell. Under the
26//!    perturbation of [`crate::sos`] no point lies *on* a sphere, so the
27//!    region is exactly the set of cells the point invalidates, and it is
28//!    star-shaped from the point.
29//! 3. Re-triangulate: delete the region and join the point to each of its
30//!    boundary faces.
31
32use std::collections::HashMap;
33use std::fmt;
34
35use axiolid_core::Point3;
36use axiolid_guarantees::Sign;
37
38use crate::hilbert;
39use crate::sos::{beyond_face, collinear, in_sphere, orientation};
40
41/// The symbolic vertex at infinity.
42const INFINITE: u32 = u32::MAX;
43
44/// Smallest non-zero coordinate magnitude accepted: `2^-100`.
45///
46/// The in-sphere test is a degree-5 polynomial and the coplanar circle test
47/// degree 6. Keeping every non-zero coordinate in `[2^-100, 2^100]` keeps
48/// every product those exact evaluations form inside binary64's normal
49/// range, which is what makes them exact.
50pub const MIN_COORDINATE: f64 = 7.888_609_052_210_118e-31;
51
52/// Largest coordinate magnitude accepted: `2^100`.
53pub const MAX_COORDINATE: f64 = 1.267_650_600_228_229_4e30;
54
55/// Why a point or a point set was refused.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum Delaunay3Error {
59    /// The point at `index` has a NaN or infinite coordinate.
60    NonFinite {
61        /// Index of the offending point.
62        index: usize,
63    },
64    /// The point at `index` has a non-zero coordinate whose magnitude lies
65    /// outside [`MIN_COORDINATE`]`..=`[`MAX_COORDINATE`], where the exact
66    /// predicates cannot be evaluated exactly.
67    OutOfRange {
68        /// Index of the offending point.
69        index: usize,
70    },
71    /// The points span fewer than three dimensions, so there is no
72    /// tetrahedron: `None` for no points, else the affine dimension (0 for
73    /// one distinct point, 1 collinear, 2 coplanar).
74    NotFullDimensional {
75        /// Affine dimension of the input, `None` when it is empty.
76        dimension: Option<usize>,
77    },
78}
79
80impl fmt::Display for Delaunay3Error {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::NonFinite { index } => write!(f, "point {index} has a non-finite coordinate"),
84            Self::OutOfRange { index } => write!(
85                f,
86                "point {index} has a coordinate outside the exactly decidable range 2^-100..=2^100"
87            ),
88            Self::NotFullDimensional { dimension: None } => write!(f, "no points"),
89            Self::NotFullDimensional {
90                dimension: Some(dimension),
91            } => write!(
92                f,
93                "the points span {dimension} dimension(s); a tetrahedralization needs 3"
94            ),
95        }
96    }
97}
98
99impl std::error::Error for Delaunay3Error {}
100
101/// What [`Delaunay3::insert`] did with a point.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum Insertion {
105    /// The point became a vertex; its index in [`Delaunay3::points`].
106    Vertex(usize),
107    /// The point repeats an existing vertex (exactly equal coordinates) and
108    /// was not inserted again; the existing vertex's index.
109    Duplicate(usize),
110}
111
112/// The finite tetrahedra of a triangulation, with their adjacency.
113#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct Tetrahedra {
115    /// Vertex indices into [`Delaunay3::points`], positively oriented: the
116    /// signed volume `(b - a) . ((c - a) x (d - a))` of `[a, b, c, d]` is
117    /// positive.
118    pub tetrahedra: Vec<[usize; 4]>,
119    /// `neighbors[t][i]` is the tetrahedron across the face opposite vertex
120    /// `i` of tetrahedron `t`, or `None` on the convex hull.
121    pub neighbors: Vec<[Option<usize>; 4]>,
122}
123
124#[derive(Debug, Clone, Copy)]
125struct Cell {
126    vertices: [u32; 4],
127    neighbors: [u32; 4],
128}
129
130impl Cell {
131    fn infinite_index(&self) -> Option<usize> {
132        self.vertices.iter().position(|&v| v == INFINITE)
133    }
134}
135
136/// An incremental 3D Delaunay triangulation of a point set.
137///
138/// Built with [`Delaunay3::from_points`] or point by point with
139/// [`Delaunay3::insert`]. Every decision is an exact sign from
140/// `axiolid-predicates`; ties (cospherical points, points in the plane of a
141/// hull face) are broken by a symbolic perturbation that depends only on the
142/// coordinates, so the result is a Delaunay triangulation -- every
143/// circumsphere has no point strictly inside -- and it is the same for every
144/// insertion order. Exact duplicates are merged, not inserted twice.
145#[derive(Debug, Clone)]
146pub struct Delaunay3 {
147    points: Vec<Point3>,
148    /// Per point, the vertex it is (itself) or duplicates.
149    vertex: Vec<u32>,
150    cells: Vec<Cell>,
151    alive: Vec<bool>,
152    free: Vec<u32>,
153    marks: Vec<u64>,
154    epoch: u64,
155    hint: u32,
156    rng: u64,
157    /// Affinely independent points found so far; four means 3D.
158    frame: Vec<u32>,
159    /// Distinct points held back until the first tetrahedron exists.
160    pending: Vec<u32>,
161    seen: HashMap<[u64; 3], u32>,
162    scratch: Scratch,
163}
164
165/// Buffers one insertion uses, kept to avoid reallocating them.
166#[derive(Debug, Clone, Default)]
167struct Scratch {
168    stack: Vec<u32>,
169    region: Vec<u32>,
170    /// New cell, slot of the inserted point, outside neighbour, and the
171    /// neighbour's slot facing the cavity.
172    created: Vec<(Cell, usize, u32, usize)>,
173    ids: Vec<u32>,
174    /// Open-addressing table: edge key, then new cell and slot.
175    edges: Vec<(u64, u64)>,
176}
177
178impl Scratch {
179    fn clear(&mut self) {
180        self.stack.clear();
181        self.region.clear();
182        self.created.clear();
183        self.ids.clear();
184        self.edges.clear();
185    }
186}
187
188impl Default for Delaunay3 {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194/// Reject non-finite and out-of-range coordinates; fold `-0.0` into `0.0`
195/// so that equal points compare equal bit for bit.
196fn check_point(p: Point3, index: usize) -> Result<Point3, Delaunay3Error> {
197    for value in [p.x, p.y, p.z] {
198        if !value.is_finite() {
199            return Err(Delaunay3Error::NonFinite { index });
200        }
201        if value != 0.0 && !(MIN_COORDINATE..=MAX_COORDINATE).contains(&value.abs()) {
202            return Err(Delaunay3Error::OutOfRange { index });
203        }
204    }
205    Ok(Point3::new(p.x + 0.0, p.y + 0.0, p.z + 0.0))
206}
207
208fn key(p: Point3) -> [u64; 3] {
209    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
210}
211
212impl Delaunay3 {
213    /// An empty triangulation.
214    #[must_use]
215    pub fn new() -> Self {
216        Self {
217            points: Vec::new(),
218            vertex: Vec::new(),
219            cells: Vec::new(),
220            alive: Vec::new(),
221            free: Vec::new(),
222            marks: Vec::new(),
223            epoch: 0,
224            hint: 0,
225            rng: 0x9E37_79B9_7F4A_7C15,
226            frame: Vec::new(),
227            pending: Vec::new(),
228            seen: HashMap::new(),
229            scratch: Scratch::default(),
230        }
231    }
232
233    /// The Delaunay triangulation of `points`.
234    ///
235    /// Points keep their indices: [`Delaunay3::points`] is `points` (with
236    /// `-0.0` read as `0.0`). They are inserted in Hilbert-curve order, which
237    /// changes only the running time.
238    ///
239    /// # Errors
240    ///
241    /// [`Delaunay3Error::NonFinite`] or [`Delaunay3Error::OutOfRange`] for the
242    /// first offending point, and [`Delaunay3Error::NotFullDimensional`] when
243    /// the points are empty, all equal, collinear or coplanar.
244    pub fn from_points(points: &[Point3]) -> Result<Self, Delaunay3Error> {
245        let mut triangulation = Self::new();
246        triangulation.points.reserve(points.len());
247        for (index, &p) in points.iter().enumerate() {
248            triangulation.points.push(check_point(p, index)?);
249        }
250        let count = u32::try_from(points.len()).map_err(|_| Delaunay3Error::OutOfRange {
251            index: u32::MAX as usize,
252        })?;
253        triangulation.vertex = (0..count).collect();
254        let all: Vec<u32> = (0..count).collect();
255        for index in hilbert::order(&triangulation.points, &all) {
256            triangulation.insert_index(index);
257        }
258        if triangulation.frame.len() < 4 {
259            return Err(Delaunay3Error::NotFullDimensional {
260                dimension: triangulation.dimension(),
261            });
262        }
263        Ok(triangulation)
264    }
265
266    /// Insert one point.
267    ///
268    /// Before four affinely independent points have arrived there is no
269    /// tetrahedron; points are held and inserted as soon as one exists.
270    ///
271    /// # Errors
272    ///
273    /// [`Delaunay3Error::NonFinite`] or [`Delaunay3Error::OutOfRange`], with
274    /// `index` the index the point would have had; the triangulation is left
275    /// unchanged.
276    pub fn insert(&mut self, point: Point3) -> Result<Insertion, Delaunay3Error> {
277        let index = self.points.len();
278        let point = check_point(point, index)?;
279        let id = u32::try_from(index)
280            .ok()
281            .filter(|&id| id != INFINITE)
282            .ok_or(Delaunay3Error::OutOfRange { index })?;
283        self.points.push(point);
284        self.vertex.push(id);
285        Ok(self.insert_index(id))
286    }
287
288    /// Every point given so far, duplicates included, by index.
289    #[must_use]
290    pub fn points(&self) -> &[Point3] {
291        &self.points
292    }
293
294    /// The vertex that point `index` became: `index` itself, or the earlier
295    /// point it exactly duplicates.
296    ///
297    /// # Panics
298    ///
299    /// If `index` is not a point index.
300    #[must_use]
301    pub fn vertex_of(&self, index: usize) -> usize {
302        self.vertex[index] as usize
303    }
304
305    /// Affine dimension of the points so far: `None` when there are none.
306    #[must_use]
307    pub fn dimension(&self) -> Option<usize> {
308        self.frame.len().checked_sub(1)
309    }
310
311    /// Number of finite tetrahedra.
312    #[must_use]
313    pub fn tetrahedron_count(&self) -> usize {
314        self.live_cells()
315            .filter(|&c| self.cells[c].infinite_index().is_none())
316            .count()
317    }
318
319    /// The finite tetrahedra and their adjacency.
320    #[must_use]
321    pub fn tetrahedra(&self) -> Tetrahedra {
322        let mut id = vec![usize::MAX; self.cells.len()];
323        let mut tetrahedra = Vec::new();
324        for c in self.live_cells() {
325            let cell = &self.cells[c];
326            if cell.infinite_index().is_none() {
327                id[c] = tetrahedra.len();
328                tetrahedra.push(cell.vertices.map(|v| v as usize));
329            }
330        }
331        let neighbors = self
332            .live_cells()
333            .filter(|&c| id[c] != usize::MAX)
334            .map(|c| {
335                self.cells[c].neighbors.map(|n| {
336                    let n = id[n as usize];
337                    (n != usize::MAX).then_some(n)
338                })
339            })
340            .collect();
341        Tetrahedra {
342            tetrahedra,
343            neighbors,
344        }
345    }
346
347    /// The convex hull's faces, as vertex triples ordered counter-clockwise
348    /// seen from outside: the normal `(b - a) x (c - a)` points out.
349    ///
350    /// Coplanar hull facets come triangulated (Delaunay within their plane,
351    /// under the same perturbation). Empty below three dimensions.
352    #[must_use]
353    pub fn hull_triangles(&self) -> Vec<[usize; 3]> {
354        self.live_cells()
355            .filter_map(|c| {
356                let cell = &self.cells[c];
357                let k = cell.infinite_index()?;
358                let mut order = cell.vertices;
359                // Move the infinite vertex to the last slot; one transposition
360                // flips the orientation, a second among the finite three
361                // restores it.
362                order.swap(k, 3);
363                if k != 3 {
364                    order.swap(0, 1);
365                }
366                Some([order[0], order[1], order[2]].map(|v| v as usize))
367            })
368            .collect()
369    }
370
371    fn live_cells(&self) -> impl Iterator<Item = usize> + '_ {
372        (0..self.cells.len()).filter(|&c| self.alive[c])
373    }
374
375    fn point(&self, v: u32) -> Point3 {
376        self.points[v as usize]
377    }
378
379    fn next_random(&mut self) -> u64 {
380        self.rng ^= self.rng << 13;
381        self.rng ^= self.rng >> 7;
382        self.rng ^= self.rng << 17;
383        self.rng
384    }
385
386    /// Insert a stored, checked point by index.
387    fn insert_index(&mut self, id: u32) -> Insertion {
388        if self.frame.len() == 4 {
389            return self.insert_3d(id);
390        }
391        let p = self.point(id);
392        if let Some(&existing) = self.seen.get(&key(p)) {
393            self.vertex[id as usize] = existing;
394            return Insertion::Duplicate(existing as usize);
395        }
396        self.seen.insert(key(p), id);
397        self.pending.push(id);
398        let raises = match self.frame.len() {
399            0 | 1 => true,
400            2 => !collinear(self.point(self.frame[0]), self.point(self.frame[1]), p),
401            _ => {
402                let [a, b, c] =
403                    [self.frame[0], self.frame[1], self.frame[2]].map(|v| self.point(v));
404                orientation(a, b, c, p) != Sign::Zero
405            }
406        };
407        if raises {
408            self.frame.push(id);
409        }
410        if self.frame.len() == 4 {
411            self.build_first_tetrahedron();
412            let pending = std::mem::take(&mut self.pending);
413            self.seen = HashMap::new();
414            let rest: Vec<u32> = pending
415                .into_iter()
416                .filter(|v| !self.frame.contains(v))
417                .collect();
418            for v in hilbert::order(&self.points, &rest) {
419                let inserted = self.insert_3d(v);
420                debug_assert_eq!(inserted, Insertion::Vertex(v as usize));
421            }
422        }
423        Insertion::Vertex(id as usize)
424    }
425
426    /// One positive tetrahedron and the four infinite cells over its faces.
427    fn build_first_tetrahedron(&mut self) {
428        let [a, b, mut c, mut d] = [self.frame[0], self.frame[1], self.frame[2], self.frame[3]];
429        if orientation(self.point(a), self.point(b), self.point(c), self.point(d)) == Sign::Negative
430        {
431            std::mem::swap(&mut c, &mut d);
432        }
433        let finite = [a, b, c, d];
434        let mut cells = vec![finite];
435        for i in 0..4 {
436            let mut ghost = finite;
437            ghost[i] = INFINITE;
438            // Beyond face i lies opposite vertex i, the negative side: swap
439            // two finite slots so the infinite vertex reads as beyond.
440            let others: Vec<usize> = (0..4).filter(|&j| j != i).collect();
441            ghost.swap(others[0], others[1]);
442            cells.push(ghost);
443        }
444        for vertices in &cells {
445            self.allocate(Cell {
446                vertices: *vertices,
447                neighbors: [INFINITE; 4],
448            });
449        }
450        // Five cells: match faces directly.
451        for x in 0..5 {
452            for i in 0..4 {
453                let face = face_key(cells[x], i);
454                for (y, other) in cells.iter().enumerate() {
455                    if y == x {
456                        continue;
457                    }
458                    if let Some(j) = (0..4).find(|&j| face_key(*other, j) == face) {
459                        self.cells[x].neighbors[i] = y as u32;
460                        debug_assert!(j < 4);
461                    }
462                }
463            }
464        }
465        self.hint = 0;
466    }
467
468    fn allocate(&mut self, cell: Cell) -> u32 {
469        if let Some(c) = self.free.pop() {
470            self.cells[c as usize] = cell;
471            self.alive[c as usize] = true;
472            c
473        } else {
474            self.cells.push(cell);
475            self.alive.push(true);
476            self.marks.push(0);
477            (self.cells.len() - 1) as u32
478        }
479    }
480
481    /// Orientation of cell `c` with its vertex at slot `slot` replaced by
482    /// point `p`; the slot may hold the infinite vertex.
483    fn orientation_with(&self, c: u32, slot: usize, p: u32) -> Sign {
484        let mut vertices = self.cells[c as usize].vertices;
485        vertices[slot] = p;
486        if vertices.contains(&INFINITE) {
487            return Sign::Zero;
488        }
489        let [a, b, x, y] = vertices.map(|v| self.point(v));
490        orientation(a, b, x, y)
491    }
492
493    /// The cell containing point `p`: a finite cell whose closure holds it,
494    /// or an infinite cell whose hull face it lies strictly beyond.
495    fn locate(&mut self, p: u32) -> u32 {
496        let mut c = if self.alive[self.hint as usize] {
497            self.hint
498        } else {
499            self.live_cells().next().map_or(0, |c| c as u32)
500        };
501        let mut previous = INFINITE;
502        let budget = 4 * self.cells.len() + 64;
503        for _ in 0..budget {
504            let cell = self.cells[c as usize];
505            if let Some(k) = cell.infinite_index() {
506                if self.orientation_with(c, k, p) == Sign::Positive {
507                    return c;
508                }
509                previous = c;
510                c = cell.neighbors[k];
511                continue;
512            }
513            let start = (self.next_random() % 4) as usize;
514            let mut moved = false;
515            for t in 0..4 {
516                let i = (start + t) % 4;
517                let next = cell.neighbors[i];
518                if next == previous {
519                    continue;
520                }
521                if self.orientation_with(c, i, p) == Sign::Negative {
522                    previous = c;
523                    c = next;
524                    moved = true;
525                    break;
526                }
527            }
528            if !moved {
529                return c;
530            }
531        }
532        self.locate_exhaustively(p)
533    }
534
535    /// Exhaustive location: the guarantee behind the walk's step budget.
536    fn locate_exhaustively(&self, p: u32) -> u32 {
537        let mut beyond = None;
538        for c in self.live_cells() {
539            let cell = &self.cells[c];
540            match cell.infinite_index() {
541                Some(k) => {
542                    if beyond.is_none() && self.orientation_with(c as u32, k, p) == Sign::Positive {
543                        beyond = Some(c as u32);
544                    }
545                }
546                None => {
547                    if (0..4).all(|i| self.orientation_with(c as u32, i, p) != Sign::Negative) {
548                        return c as u32;
549                    }
550                }
551            }
552        }
553        beyond.unwrap_or(self.hint)
554    }
555
556    fn in_conflict(&self, c: u32, p: u32) -> bool {
557        let cell = &self.cells[c as usize];
558        match cell.infinite_index() {
559            None => in_sphere(&self.points, cell.vertices, p),
560            Some(k) => {
561                // The face in the orientation that makes "beyond" positive:
562                // the infinite slot moved last, as in `hull_triangles`.
563                let mut order = cell.vertices;
564                order.swap(k, 3);
565                if k != 3 {
566                    order.swap(0, 1);
567                }
568                beyond_face(&self.points, [order[0], order[1], order[2]], p)
569            }
570        }
571    }
572
573    fn insert_3d(&mut self, p: u32) -> Insertion {
574        let start = self.locate(p);
575        let located = self.cells[start as usize];
576        if located.infinite_index().is_none() {
577            let point = self.point(p);
578            if let Some(&v) = located
579                .vertices
580                .iter()
581                .find(|&&v| key(self.point(v)) == key(point))
582            {
583                self.vertex[p as usize] = v;
584                return Insertion::Duplicate(v as usize);
585            }
586        }
587
588        // Conflict region by depth-first search. Marks: 2*epoch + 1 in
589        // conflict, 2*epoch tested and not. Buffers are reused across
590        // insertions.
591        let mut scratch = std::mem::take(&mut self.scratch);
592        scratch.clear();
593        self.epoch += 1;
594        let conflict = 2 * self.epoch + 1;
595        let clear = 2 * self.epoch;
596        scratch.stack.push(start);
597        scratch.region.push(start);
598        self.marks[start as usize] = conflict;
599        while let Some(c) = scratch.stack.pop() {
600            for i in 0..4 {
601                let n = self.cells[c as usize].neighbors[i];
602                let mark = self.marks[n as usize];
603                if mark == conflict {
604                    continue;
605                }
606                if mark != clear && self.in_conflict(n, p) {
607                    self.marks[n as usize] = conflict;
608                    scratch.stack.push(n);
609                    scratch.region.push(n);
610                } else {
611                    self.marks[n as usize] = clear;
612                    // Boundary face: slot i of conflict cell c, outside n.
613                    let j = self.cells[n as usize]
614                        .neighbors
615                        .iter()
616                        .position(|&m| m == c)
617                        .expect("adjacency is symmetric");
618                    let mut vertices = self.cells[c as usize].vertices;
619                    vertices[i] = p;
620                    let mut neighbors = [INFINITE; 4];
621                    neighbors[i] = n;
622                    scratch.created.push((
623                        Cell {
624                            vertices,
625                            neighbors,
626                        },
627                        i,
628                        n,
629                        j,
630                    ));
631                }
632            }
633        }
634
635        // New cells: each boundary face joined to p, in the slot of the
636        // vertex it replaces, which keeps the orientation.
637        for &c in &scratch.region {
638            self.alive[c as usize] = false;
639            self.free.push(c);
640        }
641        for &(cell, _, outside, j) in &scratch.created {
642            let id = self.allocate(cell);
643            self.cells[outside as usize].neighbors[j] = id;
644            scratch.ids.push(id);
645        }
646
647        // Faces between new cells contain p and one edge of the boundary;
648        // each such edge is shared by exactly two new cells. Pair them in an
649        // open-addressing table keyed by the edge.
650        let capacity = (6 * scratch.created.len()).next_power_of_two();
651        scratch.edges.clear();
652        scratch.edges.resize(capacity, (u64::MAX, 0));
653        for (k, &(cell, i, _, _)) in scratch.created.iter().enumerate() {
654            for j in (0..4).filter(|&j| j != i) {
655                let mut ends = (0..4)
656                    .filter(|&slot| slot != i && slot != j)
657                    .map(|slot| cell.vertices[slot]);
658                let (x, y) = (
659                    ends.next().unwrap_or(INFINITE),
660                    ends.next().unwrap_or(INFINITE),
661                );
662                let edge = (u64::from(x.min(y)) << 32) | u64::from(x.max(y));
663                let here = (scratch.ids[k], j as u32);
664                // Fibonacci hashing; the table is at least 1.5x the entries.
665                let mut probe = (edge.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 32) as usize;
666                loop {
667                    probe &= capacity - 1;
668                    let (stored, other) = scratch.edges[probe];
669                    if stored == u64::MAX {
670                        scratch.edges[probe] = (edge, (u64::from(here.0) << 2) | u64::from(here.1));
671                        break;
672                    }
673                    if stored == edge {
674                        let (b, l) = ((other >> 2) as u32, (other & 3) as usize);
675                        self.cells[here.0 as usize].neighbors[j] = b;
676                        self.cells[b as usize].neighbors[l] = here.0;
677                        // Each edge pairs exactly once; free the slot's key
678                        // so a stale match is impossible.
679                        scratch.edges[probe].0 = u64::MAX - 1;
680                        break;
681                    }
682                    probe += 1;
683                }
684            }
685        }
686        self.hint = scratch.ids[0];
687        self.scratch = scratch;
688        Insertion::Vertex(p as usize)
689    }
690}
691
692/// The vertices of cell face `i` (opposite slot `i`), sorted.
693fn face_key(vertices: [u32; 4], i: usize) -> [u32; 3] {
694    let mut face = [0; 3];
695    let mut k = 0;
696    for (slot, &v) in vertices.iter().enumerate() {
697        if slot != i {
698            face[k] = v;
699            k += 1;
700        }
701    }
702    face.sort_unstable();
703    face
704}