axiolid_inspect/
planes.rs

1//! Planar regions of a triangle mesh (#131).
2//!
3//! # Grown, then certified
4//!
5//! Regions grow from the largest unassigned triangle across shared edges,
6//! taking a neighbour when its normal is within the angle of the region's
7//! and its corners within the distance of the region's plane; the plane is
8//! refitted (area-weighted normal and centroid) as the region grows.
9//!
10//! Growing decides in floating point; what is reported is then proven. For
11//! the plane given -- a point and a normal, both `f64`, so an exact plane --
12//! [`DetectedPlane::deviation`] bounds the distance of every corner of every
13//! member triangle from it, computed in outward-rounded intervals. A
14//! triangle that pushes the bound past the requested distance (plus the
15//! few ulps any fitted plane is off, so exactly flat regions hold together
16//! at distance zero) is peeled off the region until the bound holds. So
17//! every region's corners, and so its whole surface, lie within
18//! `deviation` of its plane.
19//!
20//! [`DetectedPlane::coplanar`] says more when it holds: every corner lies
21//! on one plane exactly, decided by exact `orient3d`.
22
23use axiolid_core::{Point3, Vec3};
24use axiolid_exact::{certify, Arith, Dyadic, SignExpr};
25use axiolid_guarantees::Sign;
26use axiolid_mesh::TriangleMeshView;
27use std::collections::HashMap;
28
29/// How far a region may depart from a plane.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct PlaneTolerance {
32    /// Greatest distance of a corner from the region's plane.
33    pub distance: f64,
34    /// Greatest angle, in radians, between a member triangle's normal and
35    /// the plane's, while growing.
36    pub angle: f64,
37}
38
39/// One planar region.
40#[derive(Debug, Clone, PartialEq)]
41#[non_exhaustive]
42pub struct DetectedPlane {
43    /// Member triangle indices, ascending.
44    pub triangles: Vec<u32>,
45    /// A point of the plane: the members' area-weighted centroid.
46    pub point: Point3,
47    /// Unit normal (to rounding), the members' area-weighted mean, on the
48    /// side the triangles face.
49    pub normal: Vec3,
50    /// A certified upper bound on the distance of any member corner from
51    /// the plane through `point` with normal `normal`: at most the
52    /// requested distance plus a few ulps of the region's coordinates (a
53    /// lone triangle's is its own rounding).
54    pub deviation: f64,
55    /// Whether every member corner lies on one plane exactly.
56    pub coplanar: bool,
57    /// Summed area of the members (rounded).
58    pub area: f64,
59}
60
61/// Why no segmentation was made.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum PlaneError {
65    /// A coordinate is not finite.
66    NonFinite,
67    /// The distance or angle is negative or not finite.
68    InvalidTolerance,
69}
70
71/// The mesh cut into planar regions, largest first; triangles of no area
72/// belong to none.
73///
74/// # Errors
75///
76/// [`PlaneError`] for non-finite input or tolerance.
77pub fn detect_planes<M: TriangleMeshView + ?Sized>(
78    mesh: &M,
79    tolerance: PlaneTolerance,
80) -> Result<Vec<DetectedPlane>, PlaneError> {
81    let PlaneTolerance { distance, angle } = tolerance;
82    if !(distance.is_finite() && distance >= 0.0 && angle.is_finite() && angle >= 0.0) {
83        return Err(PlaneError::InvalidTolerance);
84    }
85    if (0..mesh.position_count()).any(|i| !mesh.position(i).is_finite()) {
86        return Err(PlaneError::NonFinite);
87    }
88    let n = mesh.triangle_count();
89    let corners: Vec<[u64; 3]> = (0..n).map(|t| mesh.triangle(t)).collect();
90    let at = |i: u64| mesh.position(i as usize);
91    let tri: Vec<[Point3; 3]> = corners.iter().map(|c| c.map(at)).collect();
92    let normals: Vec<Vec3> = tri
93        .iter()
94        .map(|t| (t[1] - t[0]).cross(t[2] - t[0]))
95        .collect();
96    let areas: Vec<f64> = normals.iter().map(|v| 0.5 * v.length()).collect();
97    // Edge neighbours by vertex index.
98    let mut edges: HashMap<(u64, u64), Vec<usize>> = HashMap::new();
99    for (t, c) in corners.iter().enumerate() {
100        for k in 0..3 {
101            let (a, b) = (c[k], c[(k + 1) % 3]);
102            edges.entry((a.min(b), a.max(b))).or_default().push(t);
103        }
104    }
105    let neighbours = |t: usize| -> Vec<usize> {
106        let c = corners[t];
107        let mut out = Vec::new();
108        for k in 0..3 {
109            let (a, b) = (c[k], c[(k + 1) % 3]);
110            for &o in &edges[&(a.min(b), a.max(b))] {
111                if o != t && !out.contains(&o) {
112                    out.push(o);
113                }
114            }
115        }
116        out
117    };
118    let cos_limit = angle.min(std::f64::consts::PI).cos();
119    let mut order: Vec<usize> = (0..n).filter(|&t| areas[t] > 0.0).collect();
120    order.sort_by(|&a, &b| areas[b].total_cmp(&areas[a]).then(a.cmp(&b)));
121    let mut owner = vec![usize::MAX; n];
122    let mut out: Vec<DetectedPlane> = Vec::new();
123    for &seed in &order {
124        if owner[seed] != usize::MAX {
125            continue;
126        }
127        let id = out.len();
128        let mut members = vec![seed];
129        owner[seed] = id;
130        let mut fit = Fit::of(&members, &tri, &normals, &areas);
131        let mut frontier = vec![seed];
132        let mut since_refit = 0;
133        while let Some(t) = frontier.pop() {
134            for o in neighbours(t) {
135                if owner[o] != usize::MAX || areas[o] == 0.0 {
136                    continue;
137                }
138                let unit = normals[o] / (2.0 * areas[o]);
139                if unit.dot(fit.normal) < cos_limit {
140                    continue;
141                }
142                if tri[o].iter().any(|&v| fit.distance(v) > distance) {
143                    continue;
144                }
145                owner[o] = id;
146                members.push(o);
147                frontier.push(o);
148                since_refit += 1;
149                if since_refit >= 16 {
150                    fit = Fit::of(&members, &tri, &normals, &areas);
151                    since_refit = 0;
152                }
153            }
154        }
155        // Certify; peel the worst member off while the bound fails.
156        loop {
157            fit = Fit::of(&members, &tri, &normals, &areas);
158            let bounds: Vec<f64> = members.iter().map(|&t| fit.bound(&tri[t])).collect();
159            let (worst, bound) = bounds.iter().enumerate().fold(
160                (0, 0.0f64),
161                |(wi, wb), (i, &b)| if b > wb { (i, b) } else { (wi, wb) },
162            );
163            if bound <= distance + fit.slack || members.len() == 1 {
164                // A single triangle's own plane holds it; its bound is
165                // rounding only, and is reported as it is.
166                let mut sorted: Vec<u32> = members.iter().map(|&t| t as u32).collect();
167                sorted.sort_unstable();
168                let coplanar = coplanar(members.iter().flat_map(|&t| tri[t]));
169                out.push(DetectedPlane {
170                    triangles: sorted,
171                    point: fit.point,
172                    normal: fit.normal,
173                    deviation: bound,
174                    coplanar,
175                    area: members.iter().map(|&t| areas[t]).sum(),
176                });
177                break;
178            }
179            let t = members.swap_remove(worst);
180            owner[t] = usize::MAX;
181        }
182        // Peeled triangles are seeded again in their turn; any peeled
183        // before their seed's turn came are picked up by the loop below.
184    }
185    // Triangles peeled after their turn in `order` passed.
186    let mut left: Vec<usize> = order
187        .iter()
188        .copied()
189        .filter(|&t| owner[t] == usize::MAX)
190        .collect();
191    while let Some(t) = left.pop() {
192        if owner[t] != usize::MAX {
193            continue;
194        }
195        let fit = Fit::of(&[t], &tri, &normals, &areas);
196        owner[t] = out.len();
197        out.push(DetectedPlane {
198            triangles: vec![t as u32],
199            point: fit.point,
200            normal: fit.normal,
201            deviation: fit.bound(&tri[t]),
202            coplanar: true,
203            area: areas[t],
204        });
205    }
206    out.sort_by(|a, b| {
207        b.area
208            .total_cmp(&a.area)
209            .then(a.triangles[0].cmp(&b.triangles[0]))
210    });
211    Ok(out)
212}
213
214/// A region's plane: area-weighted centroid and unit mean normal, and
215/// the rounding a plane fitted to it cannot avoid.
216struct Fit {
217    point: Point3,
218    normal: Vec3,
219    slack: f64,
220}
221
222impl Fit {
223    fn of(members: &[usize], tri: &[[Point3; 3]], normals: &[Vec3], areas: &[f64]) -> Self {
224        let mut total = 0.0;
225        let mut weighted = Vec3::ZERO;
226        let mut direction = Vec3::ZERO;
227        // About the first corner, for conditioning far from the origin.
228        let base = tri[members[0]][0];
229        for &t in members {
230            let [a, b, c] = tri[t];
231            weighted += ((a - base) + (b - base) + (c - base)) * (areas[t] / 3.0);
232            total += areas[t];
233            // `normals[t]` has length twice the area: an area weighting.
234            direction += normals[t];
235        }
236        let reach = members
237            .iter()
238            .flat_map(|&t| tri[t])
239            .map(|v| (v - base).abs().max_element())
240            .fold(0.0, f64::max);
241        let far = base.abs().max_element() + reach;
242        Self {
243            point: base + weighted / total,
244            normal: direction.normalize(),
245            slack: 32.0 * f64::EPSILON * far,
246        }
247    }
248
249    fn distance(&self, v: Point3) -> f64 {
250        self.normal.dot(v - self.point).abs()
251    }
252
253    /// A certified upper bound on the distance of each corner from the
254    /// plane through `point` with normal `normal`, both taken exactly:
255    /// `|n . (v - p)| / |n|`, in outward-rounded intervals.
256    fn bound(&self, t: &[Point3; 3]) -> f64 {
257        let n = [self.normal.x, self.normal.y, self.normal.z].map(Iv::point);
258        let norm2 = n[0].mul(n[0]).add(n[1].mul(n[1])).add(n[2].mul(n[2]));
259        t.iter()
260            .map(|v| {
261                let d = [v.x, v.y, v.z];
262                let p = [self.point.x, self.point.y, self.point.z];
263                let dot = (0..3)
264                    .map(|k| n[k].mul(Iv::point(d[k]).sub(Iv::point(p[k]))))
265                    .fold(Iv::point(0.0), Iv::add);
266                let top = dot.lo.abs().max(dot.hi.abs());
267                // |dot| / sqrt(norm2), rounded up.
268                let q = (top * top).next_up() / norm2.lo;
269                q.next_up().sqrt().next_up()
270            })
271            .fold(0.0, f64::max)
272    }
273}
274
275/// Whether the points all lie on one plane, exactly.
276fn coplanar(points: impl Iterator<Item = Point3>) -> bool {
277    let points: Vec<Point3> = points.collect();
278    let Some((a, b, c)) = spanning(&points) else {
279        return true;
280    };
281    points
282        .iter()
283        .all(|&d| certify(&Orient3 { p: [a, b, c, d] }).ok() == Some(Sign::Zero))
284}
285
286/// Three points not on one line, if any.
287fn spanning(points: &[Point3]) -> Option<(Point3, Point3, Point3)> {
288    let a = points[0];
289    let b = *points.iter().find(|&&p| p != a)?;
290    let c = *points.iter().find(|&&p| {
291        let (u, v) = (b - a, p - a);
292        let cross = [
293            exact(u.y)
294                .mul(&exact(v.z))
295                .sub(&exact(u.z).mul(&exact(v.y))),
296            exact(u.z)
297                .mul(&exact(v.x))
298                .sub(&exact(u.x).mul(&exact(v.z))),
299            exact(u.x)
300                .mul(&exact(v.y))
301                .sub(&exact(u.y).mul(&exact(v.x))),
302        ];
303        cross.iter().any(|x| x.sign() != Some(Sign::Zero))
304    })?;
305    Some((a, b, c))
306}
307
308fn exact(x: f64) -> Dyadic {
309    Dyadic::from_f64(x)
310}
311
312struct Orient3 {
313    p: [Point3; 4],
314}
315
316impl SignExpr for Orient3 {
317    fn sign_in<T: Arith>(&self) -> Option<Sign> {
318        let q = |i: usize| {
319            let p = self.p[i];
320            [T::from_f64(p.x), T::from_f64(p.y), T::from_f64(p.z)]
321        };
322        let (a, b, c, d) = (q(0), q(1), q(2), q(3));
323        let sub = |x: &[T; 3], y: &[T; 3]| [x[0].sub(&y[0]), x[1].sub(&y[1]), x[2].sub(&y[2])];
324        let (u, v, w) = (sub(&b, &a), sub(&c, &a), sub(&d, &a));
325        let n = [
326            u[1].mul(&v[2]).sub(&u[2].mul(&v[1])),
327            u[2].mul(&v[0]).sub(&u[0].mul(&v[2])),
328            u[0].mul(&v[1]).sub(&u[1].mul(&v[0])),
329        ];
330        n[0].mul(&w[0])
331            .add(&n[1].mul(&w[1]))
332            .add(&n[2].mul(&w[2]))
333            .sign()
334    }
335}
336
337/// An outward-rounded interval.
338#[derive(Debug, Clone, Copy)]
339struct Iv {
340    lo: f64,
341    hi: f64,
342}
343
344impl Iv {
345    fn point(v: f64) -> Self {
346        Self { lo: v, hi: v }
347    }
348
349    fn outward(lo: f64, hi: f64) -> Self {
350        Self {
351            lo: lo.next_down(),
352            hi: hi.next_up(),
353        }
354    }
355
356    fn add(self, o: Self) -> Self {
357        Self::outward(self.lo + o.lo, self.hi + o.hi)
358    }
359
360    fn sub(self, o: Self) -> Self {
361        Self::outward(self.lo - o.hi, self.hi - o.lo)
362    }
363
364    fn mul(self, o: Self) -> Self {
365        let p = [
366            self.lo * o.lo,
367            self.lo * o.hi,
368            self.hi * o.lo,
369            self.hi * o.hi,
370        ];
371        Self::outward(
372            p.iter().copied().fold(f64::INFINITY, f64::min),
373            p.iter().copied().fold(f64::NEG_INFINITY, f64::max),
374        )
375    }
376}