Skip to main content

ogeom_algo/
orient.rs

1//! Directions and frames read off a mesh.
2//!
3//! Four questions a scan is asked before anything is fitted to it: which
4//! ways its flat faces face ([`face_directions`]), which axis its walls turn
5//! about ([`axis_of_revolution`]), along which axes its area spreads
6//! ([`principal_frame`]), and where it is open ([`open_rims`]).
7//!
8//! Every answer is a computation over the triangles alone. The same mesh
9//! gives the same answer bit for bit on any thread count, and where two
10//! candidates tie they are told apart by where they point, never by the
11//! order they were found in.
12//!
13//! # Face directions
14//!
15//! Each triangle's normal is smoothed over its neighbours, across the edges
16//! that are not creases, so a scan's noise averages out within a face and
17//! never between two. The smoothed normals are binned on the sphere by area,
18//! a direction and its antipode in one bin, since a face and the face
19//! opposite it name one direction. Every bin holding more area than its
20//! neighbours seeds a search for the direction the area gathers at: the
21//! mean of the normals within the resolution, moved to and taken again
22//! until it stops moving. Those directions are kept by area, largest first:
23//! one within the resolution of a direction already kept is the same
24//! direction, and one is only kept where it holds more area within the
25//! resolution than in the ring from the resolution to three times it, the
26//! area of any direction already kept aside. A cylinder's normals sweep a
27//! circle and put twice as much in that ring as within the resolution, so
28//! a cylinder gives no face direction, and a plane's put none there.
29//!
30//! A triangle then belongs to the kept direction its own normal or its
31//! smoothed one is nearest, where either is within the resolution. One
32//! whose own normal is within the resolution of the direction counts with
33//! it; one claimed by its smoothed normal alone counts where its own normal
34//! is within the [`Directions::cutoff`] of the face's scatter. A scan's
35//! noise scatters a face's own normals past the resolution and the cutoff
36//! reaches out with them; an exact face's do not scatter, and a fillet's
37//! triangle that smoothing drew towards the face is left out. A face
38//! direction is the area-weighted mean of its triangles' own normals,
39//! unsmoothed: exact where the faces are exact, and for a scan's noise the
40//! mean that noise does not bias.
41
42use core::cmp::Ordering;
43use core::f64::consts::{FRAC_PI_2, FRAC_PI_4, FRAC_PI_6, PI};
44use std::collections::BTreeSet;
45
46use ogeom_core::{FastMap, OgeomResult, Tolerances, ogeom_bail, parallel};
47use ogeom_math::{Axis, Direction, Frame, Point, Vector};
48use ogeom_topo::Triangulation;
49
50use crate::Cutoff;
51use crate::recognize::{covariance, smallest_direction};
52
53/// How [`face_directions`] and [`axis_of_revolution`] read a mesh's
54/// normals.
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct Directions {
57    /// The angle, in radians, within which normals are one direction: face
58    /// directions closer than this are one, a face direction is the mean
59    /// normal of the triangles within it, and a wall is a triangle whose
60    /// normal is within it of perpendicular to an axis. At most π/6, so the
61    /// ring out to three times it stays on one side of the perpendicular.
62    pub resolution: f64,
63    /// The angle, in radians, between two neighbouring triangles' normals
64    /// at which smoothing stops: an edge this sharp is a crease, and the
65    /// faces either side of it are smoothed apart.
66    pub crease: f64,
67    /// Rounds of smoothing: each averages every triangle's normal with its
68    /// neighbours' across the edges that are not creases, by area.
69    pub rounds: u32,
70    /// How far past the resolution a face direction reaches for the
71    /// triangles only their smoothed normals put within it: to the
72    /// cutoff's threshold over the angles of its triangles' own normals
73    /// from their mean, weighed by area, and never less than the
74    /// resolution. [`Cutoff::All`] takes every one.
75    pub cutoff: Cutoff,
76}
77
78impl Default for Directions {
79    /// A resolution of 5°, a crease of 30°, three rounds and a cutoff at
80    /// three RMS: a draft of a degree or two is one direction with its
81    /// face, the angle a scan's noise of a tenth of a millimetre opens
82    /// between neighbouring triangles a millimetre across stays well inside
83    /// the crease, and three rounds average each normal with some thirty
84    /// triangles'. A face's noise spreads its normals' angles as a Rayleigh
85    /// distribution, so three RMS leave out one of a face's triangles in
86    /// some eight thousand (`e⁻⁹`).
87    fn default() -> Self {
88        Self {
89            resolution: 5.0_f64.to_radians(),
90            crease: 30.0_f64.to_radians(),
91            rounds: 3,
92            cutoff: Cutoff::Sigma(3.0),
93        }
94    }
95}
96
97impl Directions {
98    /// The options checked: a resolution in (0, π/6], a crease in (0, π]
99    /// and the cutoff's number finite and positive.
100    ///
101    /// # Errors
102    ///
103    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for any of
104    /// them out of its range or not finite.
105    pub fn check(&self) -> OgeomResult<()> {
106        self.cutoff.check()?;
107        if !(self.resolution.is_finite() && self.resolution > 0.0 && self.resolution <= FRAC_PI_6) {
108            ogeom_bail!(
109                Domain,
110                "the resolution {} must be finite, positive and at most π/6",
111                self.resolution
112            );
113        }
114        if !(self.crease.is_finite() && self.crease > 0.0 && self.crease <= PI) {
115            ogeom_bail!(
116                Domain,
117                "the crease angle {} must be finite, positive and at most π",
118                self.crease
119            );
120        }
121        Ok(())
122    }
123}
124
125/// A direction a mesh's flat faces face.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub struct FaceDirection {
128    /// The area-weighted mean normal of the triangles that face it, a
129    /// triangle facing the opposite way counted reversed. Its sense is the
130    /// one more of their area faces; where both hold the same area to
131    /// within the confusion distance's worth across a face of that area,
132    /// the sense whose largest component is positive.
133    pub direction: Direction,
134    /// The area of those triangles.
135    pub area: f64,
136    /// The area-weighted RMS angle, in radians, between their normals and
137    /// the direction: the noise, and any draft between faces merged into
138    /// one direction.
139    pub spread: f64,
140}
141
142/// The axis a mesh's walls turn about.
143#[derive(Debug, Clone, Copy, PartialEq)]
144pub struct Revolution {
145    /// The axis, located level with the walls' centroid; its sense is the
146    /// one whose largest component is positive.
147    pub axis: Axis,
148    /// The share of the mesh's area on walls about the axis: triangles
149    /// whose normal is within the resolution of pointing straight at it.
150    /// Near one for a turned part with small ends, near the resolution's
151    /// sine for a prism, whose walls point at an axis only along a strip.
152    pub share: f64,
153}
154
155/// Where a mesh is open: a chain of edges each of which only one triangle
156/// uses.
157#[derive(Debug, Clone, PartialEq)]
158pub struct Rim {
159    /// The vertices in the order the mesh's triangles traverse the rim; the
160    /// last joins the first where the rim is closed.
161    pub vertices: Vec<u32>,
162    /// The vector area of the face that would close the rim, wound the way
163    /// the mesh is: the sum of area × normal over any surface spanning it,
164    /// which is the same for every one. Its length is the opening's area
165    /// where the rim is planar, and it points out of the mesh through the
166    /// opening, so it can vote as the face that would close it.
167    pub vector_area: Vector,
168    /// The rim's centroid, by length.
169    pub centre: Point,
170    /// The greatest distance of a rim vertex from the plane through the
171    /// centre square to the vector area: zero for a planar rim.
172    pub flatness: f64,
173    /// Whether the chain returns to where it started. A rim through a
174    /// vertex where the triangles round it do not meet in one fan breaks
175    /// there, and its vector area is the chain's closed by its chord.
176    pub closed: bool,
177}
178
179/// The directions a mesh's flat faces face, largest area first.
180///
181/// See the [module documentation](self) for what is reported. A direction
182/// and its antipode are one face direction. An empty mesh, or one whose
183/// triangles all have no area, has none.
184///
185/// # Errors
186///
187/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for options out
188/// of range, or a triangle indexing a vertex the mesh does not have.
189pub fn face_directions(
190    mesh: &Triangulation,
191    options: &Directions,
192    tol: Tolerances,
193) -> OgeomResult<Vec<FaceDirection>> {
194    options.check()?;
195    let read = Read::of(mesh, options)?;
196    let kept = read.kept_directions(tol);
197    Ok(read.face_directions(&kept, tol))
198}
199
200/// The axis a mesh's walls turn about, and the share of its area on them.
201///
202/// A wall is a triangle whose normal is within the resolution of
203/// perpendicular to a direction; the axis direction is the one the walls'
204/// normals are most nearly perpendicular to, the least eigenvector of their
205/// area-weighted second moments, and the axis line the one their normal
206/// lines meet, by least squares: a normal line `(c, n)` meets the axis
207/// `(p, a)` where `(c − p)·(a × n) = 0`, which is linear in `p`. The walls
208/// are then the triangles whose normal is within the resolution of pointing
209/// straight at that line, and the two are found again from them until they
210/// stop changing. Walls at any radius serve, so a stepped shaft gives the
211/// axis its steps share.
212///
213/// The search starts from each of the face directions and from the
214/// principal directions of the normals, and keeps the axis that gathers the
215/// most wall area. A shoulder or an end is not a wall and does not count
216/// towards the share, nor does a cone's taper.
217///
218/// `None` where no direction has walls whose normal lines locate a line:
219/// an empty mesh, one plane, a sphere's single point.
220///
221/// # Errors
222///
223/// As [`face_directions`].
224pub fn axis_of_revolution(
225    mesh: &Triangulation,
226    options: &Directions,
227    tol: Tolerances,
228) -> OgeomResult<Option<Revolution>> {
229    options.check()?;
230    let read = Read::of(mesh, options)?;
231    let total: f64 = read.facets.area.iter().sum();
232    if total <= 0.0 {
233        return Ok(None);
234    }
235    let mut starts: Vec<Vector> = read.kept_directions(tol);
236    let spread = covariance(
237        read.smooth
238            .iter()
239            .zip(&read.facets.area)
240            .map(|(s, a)| *s * a.sqrt()),
241    );
242    let eigen = nalgebra::SymmetricEigen::new(spread);
243    for i in 0..3 {
244        let v = eigen.eigenvectors.column(i);
245        let v = Vector::new(v[0], v[1], v[2]);
246        if v.magnitude() > 0.0 {
247            starts.push(canonical(v / v.magnitude()));
248        }
249    }
250    let found: Vec<Option<(Vector, Point, f64)>> =
251        parallel::map_ordered(&starts, |_, start| read.revolution_from(*start, tol));
252    let mut best: Option<(Vector, Point, f64)> = None;
253    for (direction, location, area) in found.into_iter().flatten() {
254        let better = match best {
255            None => true,
256            Some((d, _, a)) => match area.total_cmp(&a) {
257                Ordering::Greater => true,
258                Ordering::Less => false,
259                Ordering::Equal => lexical(direction, d) == Ordering::Less,
260            },
261        };
262        if better {
263            best = Some((direction, location, area));
264        }
265    }
266    let Some((direction, location, area)) = best else {
267        return Ok(None);
268    };
269    Ok(Some(Revolution {
270        axis: Axis::new(location, Direction::new(direction, tol)?),
271        share: area / total,
272    }))
273}
274
275/// The frame a mesh's area spreads along: its origin at the area centroid,
276/// `x` along the greatest spread, `y` the next and `z` the least, from the
277/// second moments of the surface's area, integrated exactly over each
278/// triangle.
279///
280/// Each of `x` and `y` points the way the area's third moment along it is
281/// positive, the way the area reaches further; `z` completes a
282/// right-handed frame. A third moment smaller than moving the vertices by
283/// the confusion distance could change it does not decide, and the sense
284/// whose largest component is positive is taken instead. Spreads equal to
285/// within what that move could change do not tell their axes apart either:
286/// among them, the frame takes the world axes' projections, X first, the
287/// longest of them where it has a choice.
288///
289/// # Errors
290///
291/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for a triangle
292/// indexing a vertex the mesh does not have, and
293/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) for a
294/// mesh with no area.
295pub fn principal_frame(mesh: &Triangulation, tol: Tolerances) -> OgeomResult<Frame> {
296    check_indices(mesh)?;
297    let Some(&reference) = mesh.positions.first() else {
298        ogeom_bail!(Construction, "an empty mesh has no principal frame");
299    };
300    let corners = |t: &[u32; 3]| t.map(|i| mesh.positions[i as usize] - reference);
301    let mut area = 0.0;
302    let mut first = Vector::ZERO;
303    for t in &mesh.triangles {
304        let [a, b, c] = corners(t);
305        let w = (b - a).cross(c - a).magnitude() * 0.5;
306        if w.is_finite() {
307            area += w;
308            first += (a + b + c) * (w / 3.0);
309        }
310    }
311    if area.is_nan() || area <= 0.0 {
312        ogeom_bail!(Construction, "a mesh with no area has no principal frame");
313    }
314    let centre = first / area;
315    let mut second = nalgebra::Matrix3::<f64>::zeros();
316    for t in &mesh.triangles {
317        let [a, b, c] = corners(t).map(|p| p - centre);
318        let w = (b - a).cross(c - a).magnitude() * 0.5;
319        if !w.is_finite() || w <= 0.0 {
320            continue;
321        }
322        let s = a + b + c;
323        let column = |v: Vector| nalgebra::Vector3::new(v.x, v.y, v.z);
324        let sum = [a, b, c, s]
325            .iter()
326            .map(|v| column(*v) * column(*v).transpose())
327            .fold(nalgebra::Matrix3::zeros(), |m, x| m + x);
328        second += sum * (w / 12.0);
329    }
330    let second = second / area;
331    let eigen = nalgebra::SymmetricEigen::new(second);
332    let mut axes: Vec<(f64, Vector)> = (0..3)
333        .map(|i| {
334            let v = eigen.eigenvectors.column(i);
335            (eigen.eigenvalues[i].max(0.0), Vector::new(v[0], v[1], v[2]))
336        })
337        .collect();
338    axes.sort_by(|p, q| q.0.total_cmp(&p.0).then_with(|| lexical(p.1, q.1)));
339    let reach = tol.confusion();
340    let tied = |i: usize, j: usize| {
341        (axes[i].0 - axes[j].0).abs() <= 2.0 * reach * (axes[i].0.sqrt() + axes[j].0.sqrt())
342    };
343    let third = |e: Vector| -> f64 {
344        mesh.triangles
345            .iter()
346            .map(|t| {
347                let [a, b, c] = corners(t).map(|p| p - centre);
348                let w = (b - a).cross(c - a).magnitude() * 0.5;
349                if !w.is_finite() {
350                    return 0.0;
351                }
352                let [f, g, h] = [a.dot(e), b.dot(e), c.dot(e)];
353                let mixed = f * f * (g + h) + g * g * (f + h) + h * h * (f + g) + f * g * h;
354                w / 10.0 * (f * f * f + g * g * g + h * h * h + mixed)
355            })
356            .sum()
357    };
358    let sensed = |e: Vector, spread: f64| -> Vector {
359        let skew = third(e);
360        if skew.abs() > 3.0 * reach * area * spread {
361            if skew > 0.0 { e } else { -e }
362        } else {
363            canonical(e)
364        }
365    };
366    let world = [Vector::X, Vector::Y, Vector::Z];
367    let (x, y) = match (tied(0, 1), tied(1, 2)) {
368        (false, false) => (sensed(axes[0].1, axes[0].0), sensed(axes[1].1, axes[1].0)),
369        (true, false) => {
370            let z = sensed(axes[2].1, axes[2].0);
371            let x = projected_world(&world, z);
372            (x, z.cross(x))
373        }
374        (false, true) => {
375            let x = sensed(axes[0].1, axes[0].0);
376            (x, projected_world(&world, x))
377        }
378        (true, true) => (Vector::X, Vector::Y),
379    };
380    let z = x.cross(y);
381    let origin = reference + centre;
382    Frame::from_axes(
383        origin,
384        Direction::new(x, tol)?,
385        Direction::new(y, tol)?,
386        Direction::new(z, tol)?,
387        tol,
388    )
389}
390
391/// The rims where a mesh is open, the largest vector area first.
392///
393/// A rim edge is an edge one triangle uses; an edge two use is inside the
394/// mesh however they are wound, and one used three times or more is not a
395/// rim either. From a rim edge's end the next is found by turning through
396/// the triangles round that vertex, across the edges two of them share,
397/// until an edge only one uses: the rim the same fan of triangles bounds,
398/// so two rims that touch at a vertex stay two.
399///
400/// # Errors
401///
402/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for a triangle
403/// indexing a vertex the mesh does not have.
404pub fn open_rims(mesh: &Triangulation) -> OgeomResult<Vec<Rim>> {
405    check_indices(mesh)?;
406    let uses = edge_uses(&mesh.triangles);
407    let mut rim: Vec<(u32, u32, u32)> = Vec::new();
408    for (t, tri) in mesh.triangles.iter().enumerate() {
409        for k in 0..3 {
410            let (a, b) = (tri[k], tri[(k + 1) % 3]);
411            if a != b && uses.get(&key(a, b)).is_some_and(|u| u.count == 1) {
412                rim.push((a, b, as_index(t)));
413            }
414        }
415    }
416    let at: FastMap<(u32, u32), usize> = rim
417        .iter()
418        .enumerate()
419        .map(|(i, &(a, b, _))| ((a, b), i))
420        .collect();
421    let limit = mesh.triangles.len();
422    let next: Vec<Option<usize>> = rim
423        .iter()
424        .map(|&(a, b, t)| {
425            let mut tri = t as usize;
426            let mut far = third_vertex(mesh.triangles[tri], a, b)?;
427            for _ in 0..=limit {
428                let uses = uses.get(&key(b, far))?;
429                match uses.count {
430                    1 => return at.get(&(b, far)).copied(),
431                    2 => {
432                        let other = if uses.first as usize == tri {
433                            uses.second
434                        } else {
435                            uses.first
436                        } as usize;
437                        let turned = third_vertex(mesh.triangles[other], b, far)?;
438                        tri = other;
439                        far = turned;
440                    }
441                    _ => return None,
442                }
443            }
444            None
445        })
446        .collect();
447    let mut entered = vec![false; rim.len()];
448    for n in next.iter().flatten() {
449        entered[*n] = true;
450    }
451    let mut taken = vec![false; rim.len()];
452    let mut chains: Vec<(Vec<usize>, bool)> = Vec::new();
453    let walk = |start: usize, taken: &mut Vec<bool>| -> (Vec<usize>, bool) {
454        let mut chain = Vec::new();
455        let mut at = start;
456        loop {
457            taken[at] = true;
458            chain.push(at);
459            match next[at] {
460                Some(n) if n == start => return (chain, true),
461                Some(n) if !taken[n] => at = n,
462                _ => return (chain, false),
463            }
464        }
465    };
466    for start in 0..rim.len() {
467        if !entered[start] && !taken[start] {
468            chains.push(walk(start, &mut taken));
469        }
470    }
471    for start in 0..rim.len() {
472        if !taken[start] {
473            chains.push(walk(start, &mut taken));
474        }
475    }
476    let position = |v: u32| mesh.positions[v as usize];
477    let mut rims: Vec<Rim> = chains
478        .into_iter()
479        .map(|(mut chain, closed)| {
480            if closed {
481                let lowest = (0..chain.len())
482                    .min_by(|&i, &j| {
483                        let (a, b) = (rim[chain[i]].0, rim[chain[j]].0);
484                        lexical(position(a).to_vector(), position(b).to_vector()).then(a.cmp(&b))
485                    })
486                    .unwrap_or(0);
487                chain.rotate_left(lowest);
488            }
489            let mut vertices: Vec<u32> = chain.iter().map(|&e| rim[e].0).collect();
490            if !closed && let Some(&last) = chain.last() {
491                vertices.push(rim[last].1);
492            }
493            rim_of(vertices, closed, &position)
494        })
495        .collect();
496    rims.sort_by(|p, q| {
497        q.vector_area
498            .magnitude()
499            .total_cmp(&p.vector_area.magnitude())
500            .then_with(|| lexical(p.centre.to_vector(), q.centre.to_vector()))
501    });
502    Ok(rims)
503}
504
505/// A rim's measures from its vertices in order.
506fn rim_of(vertices: Vec<u32>, closed: bool, position: &impl Fn(u32) -> Point) -> Rim {
507    let count = vertices.len();
508    let segments = |k: usize| (position(vertices[k]), position(vertices[(k + 1) % count]));
509    let edges = if closed {
510        count
511    } else {
512        count.saturating_sub(1)
513    };
514    let mut length = 0.0;
515    let mut weighted = Vector::ZERO;
516    let reference = position(vertices[0]);
517    for k in 0..edges {
518        let (p, q) = segments(k);
519        let l = p.distance(q);
520        length += l;
521        weighted += ((p - reference) + (q - reference)) * (l * 0.5);
522    }
523    let centre = if length > 0.0 {
524        reference + weighted / length
525    } else {
526        let sum = vertices
527            .iter()
528            .fold(Vector::ZERO, |s, v| s + (position(*v) - reference));
529        #[allow(
530            clippy::cast_precision_loss,
531            reason = "vertex counts are far below 2^52"
532        )]
533        let n = count as f64;
534        reference + sum / n
535    };
536    let mut traversed = Vector::ZERO;
537    for k in 0..count {
538        let (p, q) = segments(k);
539        traversed += (p - centre).cross(q - centre);
540    }
541    let vector_area = traversed * -0.5;
542    let size = vector_area.magnitude();
543    let flatness = if size > 0.0 {
544        let n = vector_area / size;
545        vertices
546            .iter()
547            .map(|v| (position(*v) - centre).dot(n).abs())
548            .fold(0.0, f64::max)
549    } else {
550        0.0
551    };
552    Rim {
553        vertices,
554        vector_area,
555        centre,
556        flatness,
557        closed,
558    }
559}
560
561/// The vertex of a triangle that is neither `a` nor `b`.
562fn third_vertex(tri: [u32; 3], a: u32, b: u32) -> Option<u32> {
563    tri.into_iter().find(|&v| v != a && v != b)
564}
565
566/// The triangles that use an edge: how many, and the first two.
567#[derive(Debug, Clone, Copy)]
568pub(crate) struct Uses {
569    pub(crate) count: u32,
570    pub(crate) first: u32,
571    pub(crate) second: u32,
572}
573
574pub(crate) fn key(a: u32, b: u32) -> (u32, u32) {
575    (a.min(b), a.max(b))
576}
577
578fn as_index(t: usize) -> u32 {
579    #[allow(
580        clippy::cast_possible_truncation,
581        reason = "triangles are indexed by u32 throughout the mesh"
582    )]
583    let t = t as u32;
584    t
585}
586
587pub(crate) fn edge_uses(triangles: &[[u32; 3]]) -> FastMap<(u32, u32), Uses> {
588    let mut uses: FastMap<(u32, u32), Uses> = FastMap::default();
589    uses.reserve(triangles.len() * 3 / 2 + 3);
590    for (t, tri) in triangles.iter().enumerate() {
591        let t = as_index(t);
592        for k in 0..3 {
593            let (a, b) = (tri[k], tri[(k + 1) % 3]);
594            if a == b {
595                continue;
596            }
597            let entry = uses.entry(key(a, b)).or_insert(Uses {
598                count: 0,
599                first: t,
600                second: t,
601            });
602            if entry.count == 1 {
603                entry.second = t;
604            }
605            entry.count += 1;
606        }
607    }
608    uses
609}
610
611pub(crate) fn check_indices(mesh: &Triangulation) -> OgeomResult<()> {
612    let count = mesh.positions.len();
613    if let Some(t) = mesh
614        .triangles
615        .iter()
616        .find(|t| t.iter().any(|&i| i as usize >= count))
617    {
618        ogeom_bail!(
619            Domain,
620            "a triangle {t:?} indexes a vertex past the mesh's {count}"
621        );
622    }
623    Ok(())
624}
625
626/// The sense of `v` whose largest component is positive, the first of
627/// equal ones.
628fn canonical(v: Vector) -> Vector {
629    let major = major_axis(v);
630    if v.to_array()[major] < 0.0 { -v } else { v }
631}
632
633fn major_axis(v: Vector) -> usize {
634    let [x, y, z] = v.to_array().map(f64::abs);
635    if x >= y && x >= z {
636        0
637    } else if y >= z {
638        1
639    } else {
640        2
641    }
642}
643
644/// Vectors ordered by their components, x first.
645fn lexical(a: Vector, b: Vector) -> Ordering {
646    a.x.total_cmp(&b.x)
647        .then(a.y.total_cmp(&b.y))
648        .then(a.z.total_cmp(&b.z))
649}
650
651/// The world axis with the longest projection square to `normal`, X first
652/// of equal ones, projected and made unit, its sense the axis's.
653fn projected_world(world: &[Vector; 3], normal: Vector) -> Vector {
654    let mut best = Vector::ZERO;
655    for w in world {
656        let p = *w - normal * normal.dot(*w);
657        if p.magnitude() > best.magnitude() {
658            best = p;
659        }
660    }
661    best / best.magnitude()
662}
663
664/// Each triangle's unit normal, area and centroid; a triangle without a
665/// normal has no area and a zero normal.
666#[derive(Debug)]
667struct Facets {
668    normal: Vec<Vector>,
669    area: Vec<f64>,
670    centroid: Vec<Point>,
671}
672
673fn facets(mesh: &Triangulation) -> Facets {
674    let count = mesh.triangles.len();
675    let mut normal = Vec::with_capacity(count);
676    let mut area = Vec::with_capacity(count);
677    let mut centroid = Vec::with_capacity(count);
678    for t in &mesh.triangles {
679        let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
680        let cross = (b - a).cross(c - a);
681        let length = cross.magnitude();
682        if length.is_finite() && length > 0.0 {
683            normal.push(cross / length);
684            area.push(length * 0.5);
685        } else {
686            normal.push(Vector::ZERO);
687            area.push(0.0);
688        }
689        centroid.push(a + ((b - a) + (c - a)) / 3.0);
690    }
691    Facets {
692        normal,
693        area,
694        centroid,
695    }
696}
697
698/// Each triangle's normal averaged by area with its neighbours' across the
699/// edges that are not creases, `rounds` times over, every round from the
700/// last round's normals.
701fn smoothed(mesh: &Triangulation, facets: &Facets, options: &Directions) -> Vec<Vector> {
702    let mut smooth = facets.normal.clone();
703    if options.rounds == 0 {
704        return smooth;
705    }
706    let uses = edge_uses(&mesh.triangles);
707    let limit = options.crease.cos();
708    let neighbours: Vec<[u32; 3]> = mesh
709        .triangles
710        .iter()
711        .enumerate()
712        .map(|(t, tri)| {
713            let mut out = [u32::MAX; 3];
714            if facets.area[t] <= 0.0 {
715                return out;
716            }
717            for k in 0..3 {
718                let Some(u) = uses.get(&key(tri[k], tri[(k + 1) % 3])) else {
719                    continue;
720                };
721                if u.count != 2 {
722                    continue;
723                }
724                let other = if u.first as usize == t {
725                    u.second
726                } else {
727                    u.first
728                };
729                let o = other as usize;
730                if facets.area[o] > 0.0 && facets.normal[t].dot(facets.normal[o]) > limit {
731                    out[k] = other;
732                }
733            }
734            out
735        })
736        .collect();
737    for _ in 0..options.rounds {
738        let last = smooth;
739        smooth = (0..last.len())
740            .map(|t| {
741                let mut sum = last[t] * facets.area[t];
742                for &n in &neighbours[t] {
743                    if n != u32::MAX {
744                        sum += last[n as usize] * facets.area[n as usize];
745                    }
746                }
747                let m = sum.magnitude();
748                if m > 0.0 { sum / m } else { last[t] }
749            })
750            .collect();
751    }
752    smooth
753}
754
755/// An equiangular cube map over directions with a direction and its
756/// antipode in one cell: three faces of `n × n` cells, the face the one of
757/// the largest component, made positive.
758#[derive(Debug, Clone, Copy)]
759struct Grid {
760    n: u64,
761    width: f64,
762}
763
764impl Grid {
765    /// Cells half the resolution wide or narrower.
766    fn new(resolution: f64) -> Self {
767        let cells = (FRAC_PI_2 / (resolution * 0.5)).ceil().max(1.0);
768        #[allow(
769            clippy::cast_possible_truncation,
770            reason = "the resolution is at least what makes cells a few thousand a side"
771        )]
772        let n = cells as u64;
773        #[allow(clippy::cast_precision_loss, reason = "n is small")]
774        let width = FRAC_PI_2 / n as f64;
775        Self { n, width }
776    }
777
778    /// The farthest a point of a cell lies from its centre: half the
779    /// cell's diagonal in the map's angles, `w/√2`, stretched by at most
780    /// the map's greatest stretch, `2/√3`, which it reaches at the cube's
781    /// corners.
782    fn reach(self) -> f64 {
783        self.width * (2.0_f64 / 3.0).sqrt()
784    }
785
786    fn index(self, angle: f64) -> u64 {
787        let at = ((angle + FRAC_PI_4) / self.width).floor().max(0.0);
788        #[allow(
789            clippy::cast_possible_truncation,
790            reason = "clamped to the cells a side"
791        )]
792        let at = at as u64;
793        at.min(self.n - 1)
794    }
795
796    fn cell(self, d: Vector) -> u64 {
797        let d = canonical(d).to_array();
798        let m = major_axis(Vector::from_array(d));
799        let (a, b) = (d[(m + 1) % 3], d[(m + 2) % 3]);
800        let (i, j) = (self.index(a.atan2(d[m])), self.index(b.atan2(d[m])));
801        m as u64 * self.n * self.n + i * self.n + j
802    }
803
804    fn angles(self, cell: u64) -> (usize, f64, f64) {
805        let m = cell / (self.n * self.n);
806        let (i, j) = ((cell / self.n) % self.n, cell % self.n);
807        #[allow(clippy::cast_precision_loss, reason = "cell indices are small")]
808        let at = |k: u64| (k as f64 + 0.5).mul_add(self.width, -FRAC_PI_4);
809        #[allow(clippy::cast_possible_truncation, reason = "a face index is 0, 1 or 2")]
810        let m = m as usize;
811        (m, at(i), at(j))
812    }
813
814    fn direction(m: usize, u: f64, v: f64) -> Vector {
815        let mut d = [0.0; 3];
816        d[m] = 1.0;
817        d[(m + 1) % 3] = u.tan();
818        d[(m + 2) % 3] = v.tan();
819        let d = Vector::from_array(d);
820        d / d.magnitude()
821    }
822
823    fn centre(self, cell: u64) -> Vector {
824        let (m, u, v) = self.angles(cell);
825        Self::direction(m, u, v)
826    }
827
828    fn neighbours(self, cell: u64) -> impl Iterator<Item = u64> {
829        let (m, u, v) = self.angles(cell);
830        let w = self.width;
831        [-1.0, 0.0, 1.0].into_iter().flat_map(move |du| {
832            [-1.0, 0.0, 1.0].into_iter().filter_map(move |dv| {
833                if du == 0.0 && dv == 0.0 {
834                    return None;
835                }
836                let next = self.cell(Self::direction(m, u + du * w, v + dv * w));
837                (next != cell).then_some(next)
838            })
839        })
840    }
841}
842
843/// A mesh read for its normals: the facets, their smoothed normals, and
844/// both binned on the cube map.
845#[derive(Debug)]
846struct Read {
847    facets: Facets,
848    smooth: Vec<Vector>,
849    grid: Grid,
850    /// The triangles binned by their smoothed normals.
851    smoothed: Binned,
852    /// The triangles binned by their own normals.
853    own: Binned,
854    resolution: f64,
855    cutoff: Cutoff,
856}
857
858/// Triangles binned by a normal on the cube map: the cells holding one,
859/// ascending, where each cell's triangles start, and the triangles by cell
860/// and then by index.
861#[derive(Debug)]
862struct Binned {
863    cells: Vec<u64>,
864    starts: Vec<usize>,
865    members: Vec<u32>,
866}
867
868impl Binned {
869    fn of(grid: Grid, normals: &[Vector], area: &[f64]) -> Self {
870        let mut binned: Vec<(u64, u32)> = normals
871            .iter()
872            .enumerate()
873            .filter(|(t, _)| area[*t] > 0.0)
874            .map(|(t, s)| (grid.cell(*s), as_index(t)))
875            .collect();
876        binned.sort_unstable();
877        let mut cells = Vec::new();
878        let mut starts = Vec::new();
879        for (k, (cell, _)) in binned.iter().enumerate() {
880            if cells.last() != Some(cell) {
881                cells.push(*cell);
882                starts.push(k);
883            }
884        }
885        starts.push(binned.len());
886        let members = binned.into_iter().map(|(_, t)| t).collect();
887        Self {
888            cells,
889            starts,
890            members,
891        }
892    }
893
894    fn in_cell(&self, cell: u64) -> &[u32] {
895        match self.cells.binary_search(&cell) {
896            Ok(k) => &self.members[self.starts[k]..self.starts[k + 1]],
897            Err(_) => &[],
898        }
899    }
900}
901
902impl Read {
903    fn of(mesh: &Triangulation, options: &Directions) -> OgeomResult<Self> {
904        check_indices(mesh)?;
905        let facets = facets(mesh);
906        let smooth = smoothed(mesh, &facets, options);
907        let grid = Grid::new(options.resolution);
908        let smoothed = Binned::of(grid, &smooth, &facets.area);
909        let own = Binned::of(grid, &facets.normal, &facets.area);
910        Ok(Self {
911            facets,
912            smooth,
913            grid,
914            smoothed,
915            own,
916            resolution: options.resolution,
917            cutoff: options.cutoff,
918        })
919    }
920
921    fn in_cell(&self, cell: u64) -> &[u32] {
922        self.smoothed.in_cell(cell)
923    }
924
925    /// The cells that can hold a direction within `radius` of `d` or of
926    /// its antipode, ascending.
927    fn cells_near(&self, d: Vector, radius: f64) -> Vec<u64> {
928        let limit = (radius + self.grid.reach()).min(FRAC_PI_2).cos();
929        let near = |cell: u64| self.grid.centre(cell).dot(d).abs() >= limit;
930        let start = self.grid.cell(d);
931        let mut seen = BTreeSet::from([start]);
932        let mut queue = vec![start];
933        while let Some(cell) = queue.pop() {
934            for next in self.grid.neighbours(cell) {
935                if near(next) && seen.insert(next) {
936                    queue.push(next);
937                }
938            }
939        }
940        seen.into_iter().collect()
941    }
942
943    /// Every triangle whose own normal is within `radius` of `d` or of its
944    /// antipode, by cell and then by index, with that normal's dot with
945    /// `d`.
946    fn near_own(&self, d: Vector, radius: f64) -> Vec<(u32, f64)> {
947        let limit = radius.cos();
948        let mut out = Vec::new();
949        for cell in self.cells_near(d, radius) {
950            for &t in self.own.in_cell(cell) {
951                let along = self.facets.normal[t as usize].dot(d);
952                if along.abs() >= limit {
953                    out.push((t, along));
954                }
955            }
956        }
957        out
958    }
959
960    /// Every triangle whose smoothed normal is within `radius` of `d` or of
961    /// its antipode, by cell and then by index, with that normal's dot
962    /// with `d`.
963    fn near(&self, d: Vector, radius: f64) -> Vec<(u32, f64)> {
964        let limit = radius.cos();
965        let mut out = Vec::new();
966        for cell in self.cells_near(d, radius) {
967            for &t in self.in_cell(cell) {
968                let along = self.smooth[t as usize].dot(d);
969                if along.abs() >= limit {
970                    out.push((t, along));
971                }
972            }
973        }
974        out
975    }
976
977    /// The direction the area gathers at from a cell: the mean of the
978    /// smoothed normals within the resolution, aligned, taken again from
979    /// the mean until it stops moving; with the area within it.
980    fn mode(&self, cell: u64, tol: Tolerances) -> Option<(Vector, f64)> {
981        let centre = self.grid.centre(cell);
982        let mean = |members: &[(u32, f64)]| -> Option<(Vector, f64)> {
983            let mut sum = Vector::ZERO;
984            let mut area = 0.0;
985            for &(t, along) in members {
986                let a = self.facets.area[t as usize];
987                let s = self.smooth[t as usize];
988                sum += if along < 0.0 { -s } else { s } * a;
989                area += a;
990            }
991            let m = sum.magnitude();
992            (m > 0.0).then(|| (canonical(sum / m), area))
993        };
994        let seed: Vec<(u32, f64)> = self
995            .in_cell(cell)
996            .iter()
997            .map(|&t| (t, self.smooth[t as usize].dot(centre)))
998            .collect();
999        let (mut d, mut area) = mean(&seed)?;
1000        // Mean shift with a flat window settles in finitely many steps, the
1001        // area within the window growing with each; the bound only stops a
1002        // step that rounding sends back and forth between two sets.
1003        for _ in 0..64 {
1004            let (next, gathered) = mean(&self.near(d, self.resolution))?;
1005            let moved = next.cross(d).magnitude().atan2(next.dot(d).abs());
1006            d = next;
1007            area = gathered;
1008            if moved <= tol.angular() {
1009                break;
1010            }
1011        }
1012        Some((d, area))
1013    }
1014
1015    /// The directions kept from the modes, in the order they were kept.
1016    fn kept_directions(&self, tol: Tolerances) -> Vec<Vector> {
1017        let area_of = |cell: u64| -> f64 {
1018            self.in_cell(cell)
1019                .iter()
1020                .map(|&t| self.facets.area[t as usize])
1021                .sum()
1022        };
1023        let areas: Vec<f64> = self.smoothed.cells.iter().map(|&c| area_of(c)).collect();
1024        let above = |p: (f64, Vector), q: (f64, Vector)| -> bool {
1025            match p.0.total_cmp(&q.0) {
1026                Ordering::Greater => true,
1027                Ordering::Less => false,
1028                Ordering::Equal => lexical(p.1, q.1) == Ordering::Less,
1029            }
1030        };
1031        let seeds: Vec<u64> = self
1032            .smoothed
1033            .cells
1034            .iter()
1035            .zip(&areas)
1036            .filter(|&(&cell, &area)| {
1037                let own = (area, self.grid.centre(cell));
1038                self.grid.neighbours(cell).all(|n| {
1039                    let theirs = match self.smoothed.cells.binary_search(&n) {
1040                        Ok(k) => areas[k],
1041                        Err(_) => 0.0,
1042                    };
1043                    above(own, (theirs, self.grid.centre(n)))
1044                })
1045            })
1046            .map(|(&cell, _)| cell)
1047            .collect();
1048        let mut modes: Vec<(f64, Vector)> =
1049            parallel::map_ordered(&seeds, |_, &cell| self.mode(cell, tol))
1050                .into_iter()
1051                .flatten()
1052                .map(|(d, area)| (area, d))
1053                .collect();
1054        modes.sort_by(|p, q| q.0.total_cmp(&p.0).then_with(|| lexical(p.1, q.1)));
1055        let one = self.resolution.cos();
1056        let mut kept: Vec<Vector> = Vec::new();
1057        for (_, d) in modes {
1058            if kept.iter().any(|k| k.dot(d).abs() >= one) {
1059                continue;
1060            }
1061            let mut inner = 0.0;
1062            let mut ring = 0.0;
1063            for (t, along) in self.near(d, 3.0 * self.resolution) {
1064                let s = self.smooth[t as usize];
1065                if kept.iter().any(|k| k.dot(s).abs() >= one) {
1066                    continue;
1067                }
1068                let a = self.facets.area[t as usize];
1069                if along.abs() >= one {
1070                    inner += a;
1071                } else {
1072                    ring += a;
1073                }
1074            }
1075            if inner > ring {
1076                kept.push(d);
1077            }
1078        }
1079        kept
1080    }
1081
1082    /// The face directions of the kept directions: each triangle whose own
1083    /// or smoothed normal is within the resolution of one belongs to the
1084    /// one either is nearest, the first kept of equally near ones; each
1085    /// keeps those of its triangles the cutoff keeps, and is measured from
1086    /// their own normals.
1087    fn face_directions(&self, kept: &[Vector], tol: Tolerances) -> Vec<FaceDirection> {
1088        let mut owner: Vec<(usize, f64)> = vec![(usize::MAX, -1.0); self.smooth.len()];
1089        for (k, d) in kept.iter().enumerate() {
1090            let near = self.near(*d, self.resolution);
1091            for (t, along) in near.into_iter().chain(self.near_own(*d, self.resolution)) {
1092                let slot = &mut owner[t as usize];
1093                if along.abs() > slot.1 {
1094                    *slot = (k, along.abs());
1095                }
1096            }
1097        }
1098        let mut owned: Vec<Vec<u32>> = vec![Vec::new(); kept.len()];
1099        for (t, &(k, _)) in owner.iter().enumerate() {
1100            if k != usize::MAX {
1101                owned[k].push(as_index(t));
1102            }
1103        }
1104        let mut out: Vec<FaceDirection> = owned
1105            .iter()
1106            .zip(kept)
1107            .filter_map(|(triangles, &d)| {
1108                let triangles = self.cut(triangles, d);
1109                self.measured(&triangles, d, tol)
1110            })
1111            .collect();
1112        out.sort_by(|p, q| {
1113            q.area.total_cmp(&p.area).then_with(|| {
1114                lexical(
1115                    canonical(p.direction.vector()),
1116                    canonical(q.direction.vector()),
1117                )
1118            })
1119        });
1120        out
1121    }
1122
1123    /// The area-weighted mean of the triangles' own normals, each turned
1124    /// to agree with `about`.
1125    fn mean(&self, triangles: &[u32], about: Vector) -> Option<Vector> {
1126        let mut sum = Vector::ZERO;
1127        for &t in triangles {
1128            let n = self.facets.normal[t as usize];
1129            let a = self.facets.area[t as usize];
1130            sum += if n.dot(about) < 0.0 { -n } else { n } * a;
1131        }
1132        let m = sum.magnitude();
1133        (m > 0.0).then(|| sum / m)
1134    }
1135
1136    /// The triangles the cutoff keeps of those a direction owns. One whose
1137    /// own normal is within the resolution of the mean is always kept:
1138    /// directions that near are one. One owned by its smoothed normal alone
1139    /// is kept where its own normal is within the cutoff's threshold of the
1140    /// mean, measured over the angles of the triangles kept, weighed by
1141    /// area: a noisy face's own normals scatter past the resolution and the
1142    /// threshold reaches out with them, while an exact face's do not
1143    /// scatter at all, and a fillet's triangle that smoothing drew towards
1144    /// it is left out. Each round takes the mean of the last round's kept
1145    /// triangles, and the rounds repeat until the kept set stops changing.
1146    fn cut(&self, owned: &[u32], about: Vector) -> Vec<u32> {
1147        let angle_from = |d: Vector| {
1148            move |t: u32| {
1149                let n = self.facets.normal[t as usize];
1150                n.cross(d).magnitude().atan2(n.dot(d).abs())
1151            }
1152        };
1153        let within: Vec<u32> = owned
1154            .iter()
1155            .copied()
1156            .filter(|&t| angle_from(about)(t) <= self.resolution)
1157            .collect();
1158        let mut kept = if within.is_empty() {
1159            owned.to_vec()
1160        } else {
1161            within
1162        };
1163        // The kept set only changes while the threshold does; the bound
1164        // stops a set that rounding sends back and forth between two.
1165        for _ in 0..64 {
1166            let Some(d) = self.mean(&kept, about) else {
1167                break;
1168            };
1169            let angle = angle_from(d);
1170            let weighed: Vec<(f64, f64)> = kept
1171                .iter()
1172                .map(|&t| (angle(t), self.facets.area[t as usize]))
1173                .collect();
1174            let limit = self
1175                .cutoff
1176                .threshold_weighted(&weighed)
1177                .max(self.resolution);
1178            let next: Vec<u32> = owned
1179                .iter()
1180                .copied()
1181                .filter(|&t| angle(t) <= limit)
1182                .collect();
1183            if next.is_empty() || next == kept {
1184                break;
1185            }
1186            kept = next;
1187        }
1188        kept
1189    }
1190
1191    /// A face direction measured from its triangles' own normals.
1192    fn measured(&self, triangles: &[u32], about: Vector, tol: Tolerances) -> Option<FaceDirection> {
1193        let d = canonical(self.mean(triangles, about)?);
1194        let area: f64 = triangles
1195            .iter()
1196            .map(|&t| self.facets.area[t as usize])
1197            .sum();
1198        let mut net = 0.0;
1199        let mut square = 0.0;
1200        for &t in triangles {
1201            let n = self.facets.normal[t as usize];
1202            let a = self.facets.area[t as usize];
1203            let angle = n.cross(d).magnitude().atan2(n.dot(d).abs());
1204            net += a * n.dot(d);
1205            square += a * angle * angle;
1206        }
1207        let d = if net < -tol.confusion() * area.sqrt() {
1208            -d
1209        } else {
1210            d
1211        };
1212        Some(FaceDirection {
1213            direction: Direction::new(d, tol).ok()?,
1214            area,
1215            spread: (square / area).sqrt(),
1216        })
1217    }
1218
1219    /// The axis found from `start`, with the wall area about it.
1220    fn revolution_from(&self, start: Vector, tol: Tolerances) -> Option<(Vector, Point, f64)> {
1221        let sine = self.resolution.sin();
1222        let walls = |a: Vector| -> Vec<u32> {
1223            (0..self.smooth.len())
1224                .filter(|&t| self.facets.area[t] > 0.0 && self.smooth[t].dot(a).abs() <= sine)
1225                .map(as_index)
1226                .collect()
1227        };
1228        let about = |a: Vector, p: Point| -> Vec<u32> {
1229            walls(a)
1230                .into_iter()
1231                .filter(|&t| {
1232                    let w = self.facets.centroid[t as usize] - p;
1233                    let radial = w - a * w.dot(a);
1234                    let r = radial.magnitude();
1235                    r > 0.0 && self.smooth[t as usize].dot(a.cross(radial) / r).abs() <= sine
1236                })
1237                .collect()
1238        };
1239        let mut a = start;
1240        let mut chosen = walls(a);
1241        let mut p = self.located(a, &chosen, tol)?;
1242        // Each round's walls are the triangles pointing at the last round's
1243        // axis; the bound only stops a set that rounding sends back and
1244        // forth.
1245        for _ in 0..64 {
1246            let next = about(a, p);
1247            if next.is_empty() {
1248                return None;
1249            }
1250            a = self.least_normal(&next)?;
1251            p = self.located(a, &next, tol)?;
1252            if next == chosen {
1253                break;
1254            }
1255            chosen = next;
1256        }
1257        let mut area = 0.0;
1258        let mut weighted = Vector::ZERO;
1259        for &t in &chosen {
1260            let w = self.facets.area[t as usize];
1261            area += w;
1262            weighted += (self.facets.centroid[t as usize] - p) * w;
1263        }
1264        let level = p + a * (weighted / area).dot(a);
1265        Some((a, level, area))
1266    }
1267
1268    /// The direction the triangles' normals are most nearly perpendicular
1269    /// to.
1270    fn least_normal(&self, triangles: &[u32]) -> Option<Vector> {
1271        let m = covariance(
1272            triangles
1273                .iter()
1274                .map(|&t| self.facets.normal[t as usize] * self.facets.area[t as usize].sqrt()),
1275        );
1276        let d = smallest_direction(m);
1277        let length = d.magnitude();
1278        (length > 0.0).then(|| canonical(d / length))
1279    }
1280
1281    /// The point, square to `a`, of the line along `a` the triangles'
1282    /// normal lines come nearest meeting, by area-weighted least squares;
1283    /// `None` where their normals do not span the plane square to `a`.
1284    fn located(&self, a: Vector, triangles: &[u32], tol: Tolerances) -> Option<Point> {
1285        let reference = self.facets.centroid[*triangles.first()? as usize];
1286        let e1 = Direction::new(a, tol).ok()?.any_perpendicular().vector();
1287        let e2 = a.cross(e1);
1288        let (mut m11, mut m12, mut m22, mut b1, mut b2) = (0.0, 0.0, 0.0, 0.0, 0.0);
1289        for &t in triangles {
1290            let w = self.facets.area[t as usize];
1291            let m = a.cross(self.facets.normal[t as usize]);
1292            let (x, y) = (m.dot(e1), m.dot(e2));
1293            let rhs = (self.facets.centroid[t as usize] - reference).dot(m);
1294            m11 += w * x * x;
1295            m12 += w * x * y;
1296            m22 += w * y * y;
1297            b1 += w * x * rhs;
1298            b2 += w * y * rhs;
1299        }
1300        let det = m11.mul_add(m22, -m12 * m12);
1301        let trace = m11 + m22;
1302        let angular = tol.angular();
1303        if det.is_nan() || det <= angular * angular * trace * trace {
1304            return None;
1305        }
1306        let x = (b1 * m22 - b2 * m12) / det;
1307        let y = (b2 * m11 - b1 * m12) / det;
1308        Some(reference + e1 * x + e2 * y)
1309    }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    #![allow(clippy::unwrap_used, reason = "test code")]
1315    use super::*;
1316
1317    const T: Tolerances = Tolerances::millimetres();
1318
1319    #[test]
1320    fn every_direction_lands_in_a_cell_its_antipode_shares_and_its_centre_is_near() {
1321        let grid = Grid::new(5.0_f64.to_radians());
1322        let mut seed = 0x9e37_79b9_7f4a_7c15_u64;
1323        let mut unit = || {
1324            seed ^= seed << 13;
1325            seed ^= seed >> 7;
1326            seed ^= seed << 17;
1327            #[allow(clippy::cast_precision_loss, reason = "test draws")]
1328            let u = (seed >> 11) as f64 / (1_u64 << 53) as f64;
1329            2.0f64.mul_add(u, -1.0)
1330        };
1331        for _ in 0..20_000 {
1332            let v = Vector::new(unit(), unit(), unit());
1333            if v.magnitude() < 1e-3 {
1334                continue;
1335            }
1336            let d = v / v.magnitude();
1337            let cell = grid.cell(d);
1338            assert_eq!(cell, grid.cell(-d));
1339            let c = grid.centre(cell);
1340            let angle = c.cross(d).magnitude().atan2(c.dot(d).abs());
1341            assert!(angle <= grid.reach(), "{angle} past {}", grid.reach());
1342        }
1343    }
1344
1345    #[test]
1346    fn a_resolution_out_of_range_is_refused() {
1347        for resolution in [0.0, -0.1, f64::NAN, 0.6] {
1348            let options = Directions {
1349                resolution,
1350                ..Directions::default()
1351            };
1352            assert!(options.check().is_err());
1353        }
1354        assert!(
1355            Directions {
1356                crease: 0.0,
1357                ..Directions::default()
1358            }
1359            .check()
1360            .is_err()
1361        );
1362        let bad = Triangulation {
1363            positions: vec![Point::ORIGIN],
1364            triangles: vec![[0, 1, 2]],
1365            ..Triangulation::new()
1366        };
1367        assert!(face_directions(&bad, &Directions::default(), T).is_err());
1368        assert!(open_rims(&bad).is_err());
1369    }
1370
1371    #[test]
1372    fn an_empty_mesh_has_no_directions_no_axis_and_no_rims() {
1373        let empty = Triangulation::new();
1374        assert!(
1375            face_directions(&empty, &Directions::default(), T)
1376                .unwrap()
1377                .is_empty()
1378        );
1379        assert!(
1380            axis_of_revolution(&empty, &Directions::default(), T)
1381                .unwrap()
1382                .is_none()
1383        );
1384        assert!(open_rims(&empty).unwrap().is_empty());
1385        assert!(principal_frame(&empty, T).is_err());
1386    }
1387}