Skip to main content

ogeom_algo/
thickness.rs

1//! Wall thickness at many points of a mesh or a shape, by rays or by
2//! inscribed spheres.
3//!
4//! [`thickness`] measures against a triangle mesh through its [`Bvh`], and
5//! [`thickness_of_shape`] against a B-rep's own surfaces, never a
6//! tessellation of them. Each point carries its outward normal, and the
7//! wall is measured into the material from the point, against the normal.
8//! Both answer every point independently, in parallel, in the points'
9//! order, and the same points give the same answers bit for bit on any
10//! thread count.
11//!
12//! # Rays
13//!
14//! [`Method::Ray`] casts from the point along its inward normal and reads
15//! the distance to the first surface the ray crosses, on either side of it:
16//! a triangle's winding, or a face's orientation, does not matter. With a
17//! cone, `rays` rays are cast, the first along the normal and the others
18//! spread over the cone by a golden-angle spiral that covers its solid
19//! angle evenly, and the shortest reading wins.
20//!
21//! # Spheres
22//!
23//! [`Method::Sphere`] reads the diameter of the largest ball that touches
24//! the point, has its centre on the inward normal, and holds no surface: the
25//! medial ball through the point, whose diameter is the wall measured
26//! square across it. Every such ball is tangent to the point's tangent
27//! plane, so the smaller ones nest in the larger, and the search starts
28//! from the ball centred halfway along the inward ray to its first
29//! crossing, which the ray's far end bounds: the ball is never longer than
30//! the ray. A ball tangent at `p` along the inward unit normal `u` passes
31//! through `x` at radius `|x − p|² / (2 (x − p)·u)`, the circumradius of
32//! the shrinking-ball iteration.
33//!
34//! On a mesh the medial ball is found in one pass, exactly: a triangle
35//! outside the starting ball is outside every ball nested in it, so the
36//! medial radius is the least, over the triangles the starting ball holds,
37//! of the radius at which a ball growing from `p` first touches each one
38//! (inside its face where the ball is tangent to its plane, on an edge
39//! where it is tangent to the edge's line, or at a corner, whichever is
40//! least). The ball is the medial ball of the mesh as faceted: where it
41//! would touch a convex curved wall along a whole circle, as a rod's
42//! across its diameter, the facets turned in round the point it touches
43//! cut it, and it reads less than the smooth wall would.
44//!
45//! On a shape the iteration is the shrinking ball's: the surface nearest
46//! the centre is found by projection, and while it lies inside the ball by
47//! more than the confusion distance the ball shrinks to the one through
48//! it. A step from a point `δ` inside a ball of radius `r` shrinks it by
49//! `δ (2r − δ) / (2 (x − p)·u)`, at least `δ / 4`, so the steps end, with a
50//! ball that holds no surface deeper inside it than the confusion distance.
51//!
52//! A wedge between two planes at an angle `α` therefore reads `2 s tan(α/2)`
53//! at a distance `s` from its edge along one face, where a ray along the
54//! normal reads `s tan α`.
55//!
56//! # What is measured to
57//!
58//! A surface within [`Thickness::facing_limit`] of edge-on to the ray (for
59//! a sphere, to the point's normal) is passed over: the side walls of a
60//! plate do not cut off the plate's thickness near its rim. On a mesh, the
61//! triangles within the confusion distance of the point are its own and
62//! are passed over too: a ball that touches a corner of a faceted surface
63//! would otherwise be cut by the facets meeting there. On a shape the faces
64//! the point lies on are passed over at the ray's start only, so the far
65//! side of a curved face that folds back is still met. Those are the faces
66//! that hold the point within their tolerance, and where it lies in the
67//! band of a face's trim, as a point read off an edge in single precision
68//! does, the faces that hold its nearest point on the shape: it is on the
69//! edge to within the band, and on the faces meeting there. A ball passes
70//! over those faces, and the edges and vertices they alone hold, where they
71//! are nearest its centre on its radius from the point: at the point
72//! itself, or edge-on through the centre, as the ray passed over them.
73
74mod shape;
75
76use ogeom_core::{OgeomResult, Tolerances, ogeom_bail, parallel};
77use ogeom_math::{Point, Vector};
78use ogeom_mesh::Bvh;
79
80pub use shape::thickness_of_shape;
81
82/// How [`thickness`] and [`thickness_of_shape`] read a wall.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub enum Method {
85    /// Along rays from the point into the material: the shortest of `rays`
86    /// rays, the first along the inward normal and the rest spread within
87    /// `cone` radians of it, in `[0, π/2)`. One ray, or no cone, casts
88    /// along the normal alone.
89    Ray {
90        /// The half-angle of the cone the rays are spread over.
91        cone: f64,
92        /// How many rays to cast, at least one.
93        rays: u32,
94    },
95    /// The diameter of the largest ball touching the point with its centre
96    /// on the inward normal and no surface inside.
97    Sphere,
98}
99
100/// How [`thickness`] and [`thickness_of_shape`] measure.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct Thickness {
103    /// Rays or spheres.
104    pub method: Method,
105    /// The angle, in radians in `[0, π/2]`, from edge-on to the ray within
106    /// which a surface is passed over. Zero passes over only a surface
107    /// exactly edge-on.
108    pub facing_limit: f64,
109    /// The thickest a wall may read; a point whose wall is thicker answers
110    /// `None`. May be infinite.
111    pub max: f64,
112    /// The tolerances: on a mesh, the triangles within their confusion
113    /// distance of a point are its own; on a shape, the precision faces
114    /// are met and projected to.
115    pub tol: Tolerances,
116}
117
118impl Default for Thickness {
119    /// Along the normal alone, passing over surfaces exactly edge-on, at
120    /// any thickness, in millimetres.
121    fn default() -> Self {
122        Self {
123            method: Method::Ray { cone: 0.0, rays: 1 },
124            facing_limit: 0.0,
125            max: f64::INFINITY,
126            tol: Tolerances::millimetres(),
127        }
128    }
129}
130
131impl Thickness {
132    /// The options checked: `max` not negative or undefined, the facing
133    /// limit in `[0, π/2]`, and for rays at least one ray and a cone in
134    /// `[0, π/2)`.
135    ///
136    /// # Errors
137    ///
138    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for any of
139    /// them out of its range.
140    pub fn check(&self) -> OgeomResult<()> {
141        if self.max.is_nan() || self.max < 0.0 {
142            ogeom_bail!(
143                Domain,
144                "the thickest wall {} must not be negative",
145                self.max
146            );
147        }
148        if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&self.facing_limit) {
149            ogeom_bail!(
150                Domain,
151                "the facing limit {} must be in [0, π/2]",
152                self.facing_limit
153            );
154        }
155        if let Method::Ray { cone, rays } = self.method {
156            if rays == 0 {
157                ogeom_bail!(Domain, "at least one ray must be cast");
158            }
159            if !(0.0..core::f64::consts::FRAC_PI_2).contains(&cone) {
160                ogeom_bail!(Domain, "the cone {cone} must be in [0, π/2)");
161            }
162        }
163        Ok(())
164    }
165
166    /// The options checked against the points and their normals, and the
167    /// sine of the facing limit: a surface is met where its unit normal's
168    /// dot with the unit ray is larger than it in magnitude.
169    fn checked(&self, points: usize, normals: usize) -> OgeomResult<f64> {
170        self.check()?;
171        if normals != points {
172            ogeom_bail!(Dimension, "{normals} normals for {points} points");
173        }
174        Ok(self.facing_limit.sin())
175    }
176}
177
178/// The wall thickness at each point of the mesh `target` was built over,
179/// measured into the material against the point's outward normal; in the
180/// points' order, `None` where it is thicker than [`Thickness::max`] or the
181/// inward ray leaves the mesh without crossing it.
182///
183/// See the [module](self) for what each method reads and what it measures
184/// to. The points are meant to lie on the mesh (a scan's vertices, with
185/// their normals): a point off it by more than the confusion distance has
186/// no triangles of its own, and reads the surface it stands off as its
187/// wall. A point that is not finite, or whose normal has no direction,
188/// answers `None`.
189///
190/// # Errors
191///
192/// As [`Thickness::check`];
193/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if there is
194/// not one normal per point.
195pub fn thickness(
196    target: &Bvh,
197    points: &[Point],
198    normals: &[Vector],
199    options: &Thickness,
200) -> OgeomResult<Vec<Option<f64>>> {
201    let sin = options.checked(points.len(), normals.len())?;
202    let faces = face_normals(target);
203    let mesh = Mesh {
204        target,
205        faces: &faces,
206        sin,
207        options,
208    };
209    Ok(parallel::map_ordered(points, |i, &p| {
210        let u = inward(p, normals[i])?;
211        mesh.at(p, u)
212    }))
213}
214
215/// The unit inward normal for a point and its outward normal, or `None`
216/// where the point is not finite or the normal has no direction.
217fn inward(p: Point, normal: Vector) -> Option<Vector> {
218    if !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) {
219        return None;
220    }
221    let length = normal.magnitude();
222    (length > 0.0 && length.is_finite()).then(|| -normal / length)
223}
224
225/// Each triangle's unit normal, zero for a degenerate one.
226fn face_normals(target: &Bvh) -> Vec<Vector> {
227    (0..target.triangle_count())
228        .map(|t| {
229            u32::try_from(t)
230                .ok()
231                .and_then(|t| target.triangle(t))
232                .map_or(Vector::ZERO, |[a, b, c]| {
233                    let n = (b - a).cross(c - a);
234                    let length = n.magnitude();
235                    if length > 0.0 && length.is_finite() {
236                        n / length
237                    } else {
238                        Vector::ZERO
239                    }
240                })
241        })
242        .collect()
243}
244
245/// Whether a surface with unit normal `m` is met by a ray along the unit
246/// `v`: turned further than the facing limit, whose sine is `sin`, from
247/// edge-on. A surface with no normal is met by nothing.
248fn meets(m: Vector, v: Vector, sin: f64) -> bool {
249    m.dot(v).abs() > sin
250}
251
252/// The radius at which a ball tangent at `p` along the unit `u` passes
253/// through `x`, or `None` where no ball tangent there does.
254fn through(p: Point, u: Vector, x: Point) -> Option<f64> {
255    let v = x - p;
256    let height = v.dot(u);
257    (height > 0.0).then(|| v.dot(v) / (2.0 * height))
258}
259
260/// The `k`th of `rays` directions spread within `cone` of the unit `axis`:
261/// the axis first, then a golden-angle spiral whose rings are equally
262/// spaced in the cosine of their angle from the axis, out to the cone's
263/// rim, so each ray stands for an equal share of the cone's solid angle.
264fn spread(axis: Vector, cone: f64, rays: u32, k: u32) -> Vector {
265    if k == 0 || rays < 2 {
266        return axis;
267    }
268    let (b1, b2) = basis(axis);
269    let share = f64::from(k) / f64::from(rays - 1);
270    let cos = 1.0 - share * (1.0 - cone.cos());
271    let sin = (1.0 - cos * cos).max(0.0).sqrt();
272    let turn = f64::from(k) * core::f64::consts::PI * (3.0 - 5.0_f64.sqrt());
273    let v = axis * cos + (b1 * turn.cos() + b2 * turn.sin()) * sin;
274    v / v.magnitude()
275}
276
277/// Two unit vectors completing the unit `n` to a right-handed orthonormal
278/// frame, continuous everywhere but where `n.z` changes sign.
279fn basis(n: Vector) -> (Vector, Vector) {
280    let sign = 1.0_f64.copysign(n.z);
281    let a = -1.0 / (sign + n.z);
282    let b = n.x * n.y * a;
283    (
284        Vector::new(1.0 + sign * n.x * n.x * a, sign * b, -sign * n.x),
285        Vector::new(b, sign + n.y * n.y * a, -n.y),
286    )
287}
288
289/// A mesh measured against, with what every point asks of it.
290struct Mesh<'a> {
291    target: &'a Bvh,
292    faces: &'a [Vector],
293    sin: f64,
294    options: &'a Thickness,
295}
296
297impl Mesh<'_> {
298    /// The wall at `p`, inward along the unit `u`.
299    fn at(&self, p: Point, u: Vector) -> Option<f64> {
300        let own = self.target.within_ball(p, self.options.tol.confusion());
301        let max = self.options.max;
302        match self.options.method {
303            Method::Ray { cone, rays } => (0..rays)
304                .filter_map(|k| self.ray(p, spread(u, cone, rays, k), max, &own))
305                .min_by(f64::total_cmp),
306            Method::Sphere => {
307                let ray = self.ray(p, u, max, &own);
308                self.sphere(p, u, ray, &own)
309            }
310        }
311    }
312
313    /// The distance along the unit `v` from `p` to the first triangle met,
314    /// not `p`'s own, within `max`.
315    fn ray(&self, p: Point, v: Vector, max: f64, own: &[u32]) -> Option<f64> {
316        self.target
317            .hits_along(p, v, max, true)
318            .into_iter()
319            .find(|hit| {
320                own.binary_search(&hit.triangle).is_err()
321                    && meets(self.faces[hit.triangle as usize], v, self.sin)
322            })
323            .map(|hit| hit.distance)
324    }
325
326    /// The diameter of the medial ball at `p` along the unit `u`, shrunk
327    /// from half the inward ray's reading `ray`, or from half the thickest
328    /// wall where the ray met nothing within it.
329    fn sphere(&self, p: Point, u: Vector, ray: Option<f64>, own: &[u32]) -> Option<f64> {
330        let mut radius = match ray {
331            Some(length) => length / 2.0,
332            None if self.options.max.is_finite() => self.options.max / 2.0,
333            None => return None,
334        };
335        // Every ball smaller than this one nests in it, so a triangle
336        // outside it is outside them all: the least touching radius over
337        // the triangles it holds is the medial ball's.
338        let mut bounded = ray.is_some();
339        let start = radius;
340        for t in self.target.within_ball(p + u * start, start) {
341            if own.binary_search(&t).is_ok() || !meets(self.faces[t as usize], u, self.sin) {
342                continue;
343            }
344            bounded = true;
345            if let Some(r) = touch(p, u, self.target.triangle(t)?)
346                && r < radius
347            {
348                radius = r;
349            }
350        }
351        bounded.then_some(2.0 * radius)
352    }
353}
354
355/// The radius at which a ball tangent at `p` along the unit `u` first
356/// touches the triangle `t`, growing from nothing, or `None` where none
357/// does: the least of the radii through each point of it, found inside
358/// the face where the ball is tangent to its plane, on an edge where it is
359/// tangent to the edge's line, or at a corner.
360fn touch(p: Point, u: Vector, t: [Point; 3]) -> Option<f64> {
361    let mut least: Option<f64> = None;
362    let mut offer = |r: f64| {
363        if r.is_finite() && least.is_none_or(|held| r < held) {
364            least = Some(r);
365        }
366    };
367    for &x in &t {
368        if let Some(r) = through(p, u, x) {
369            offer(r);
370        }
371    }
372    for k in 0..3 {
373        let (a, b) = (t[k], t[(k + 1) % 3]);
374        for s in edge_critical(p, u, a, b) {
375            if let Some(r) = through(p, u, a + (b - a) * s) {
376                offer(r);
377            }
378        }
379    }
380    let normal = (t[1] - t[0]).cross(t[2] - t[0]);
381    let length = normal.magnitude();
382    if length > 0.0 && length.is_finite() {
383        let mut m = normal / length;
384        let mut offset = (t[0] - p).dot(m);
385        if offset < 0.0 {
386            m = -m;
387            offset = -offset;
388        }
389        let lean = 1.0 + u.dot(m);
390        if offset > 0.0 && lean > 0.0 {
391            let r = offset / lean;
392            // The ball's centre is `r` short of the plane along `m`, so it
393            // touches the plane at the centre moved `r` along `m`.
394            let x = p + u * r + m * r;
395            if inside(t, normal, x) {
396                offer(r);
397            }
398        }
399    }
400    least
401}
402
403/// The parameters in `(0, 1)` along the edge from `a` to `b` where the
404/// radius of the ball tangent at `p` along `u` through the edge's point is
405/// stationary: the roots of `Aβ s² + 2Aα s + (2Bα − Cβ)`, with `f = a − p`,
406/// `e = b − a`, `A = e·e`, `B = f·e`, `C = f·f`, `α = f·u` and `β = e·u`,
407/// the derivative's numerator.
408fn edge_critical(p: Point, u: Vector, a: Point, b: Point) -> impl Iterator<Item = f64> {
409    let (f, e) = (a - p, b - a);
410    let (aa, bb, cc) = (e.dot(e), f.dot(e), f.dot(f));
411    let (alpha, beta) = (f.dot(u), e.dot(u));
412    let quadratic = aa * beta;
413    let linear = 2.0 * aa * alpha;
414    let constant = 2.0 * bb * alpha - cc * beta;
415    let mut roots = [f64::NAN; 2];
416    if quadratic == 0.0 {
417        if linear != 0.0 {
418            roots[0] = -constant / linear;
419        }
420    } else {
421        let discriminant = linear * linear - 4.0 * quadratic * constant;
422        if discriminant >= 0.0 {
423            let q = -0.5 * (linear + discriminant.sqrt().copysign(linear));
424            roots[0] = q / quadratic;
425            if q != 0.0 {
426                roots[1] = constant / q;
427            }
428        }
429    }
430    roots.into_iter().filter(|s| *s > 0.0 && *s < 1.0)
431}
432
433/// Whether `x`, in the triangle's plane, lies inside the triangle or on its
434/// edges, against the triangle's own normal `normal`.
435fn inside(t: [Point; 3], normal: Vector, x: Point) -> bool {
436    (0..3).all(|k| {
437        let (a, b) = (t[k], t[(k + 1) % 3]);
438        (b - a).cross(x - a).dot(normal) >= 0.0
439    })
440}
441
442#[cfg(test)]
443#[allow(clippy::unwrap_used)]
444mod tests {
445    use super::*;
446
447    /// A ball tangent at the origin along `+z` touches a triangle far below
448    /// a tilted plane where the closed form for the plane says, a corner
449    /// where only the corner reaches, and an edge at the edge's own
450    /// tangency, each checked against a dense scan of the triangle.
451    #[test]
452    fn the_touching_radius_is_the_least_over_the_triangle() {
453        let p = Point::ORIGIN;
454        let u = Vector::Z;
455        let cases = [
456            [
457                Point::new(-5.0, -5.0, 2.0),
458                Point::new(5.0, -5.0, 3.0),
459                Point::new(0.0, 6.0, 2.5),
460            ],
461            [
462                Point::new(3.0, 0.0, 1.0),
463                Point::new(6.0, 1.0, 1.5),
464                Point::new(5.0, 4.0, 2.0),
465            ],
466            [
467                Point::new(-4.0, 2.0, 1.0),
468                Point::new(4.0, 2.0, 1.0),
469                Point::new(0.0, 6.0, 9.0),
470            ],
471        ];
472        for t in cases {
473            let found = touch(p, u, t).unwrap();
474            let mut least = f64::INFINITY;
475            let n = 600;
476            for i in 0..=n {
477                for j in 0..=(n - i) {
478                    let (a, b) = (f64::from(i) / f64::from(n), f64::from(j) / f64::from(n));
479                    let x = t[0] + (t[1] - t[0]) * a + (t[2] - t[0]) * b;
480                    if let Some(r) = through(p, u, x) {
481                        least = least.min(r);
482                    }
483                }
484            }
485            assert!(found <= least + 1e-12, "{found} above the scan's {least}");
486            assert!(
487                found >= least - 1e-4 * least,
488                "{found} below the scan's {least}"
489            );
490        }
491    }
492
493    /// The spread rays start on the axis, stay within the cone, and reach
494    /// its rim.
495    #[test]
496    fn spread_rays_cover_the_cone_out_to_its_rim() {
497        let axis = Vector::new(0.3, -0.5, -0.8);
498        let axis = axis / axis.magnitude();
499        let cone = 0.4;
500        let rays = 50;
501        let angles: Vec<f64> = (0..rays)
502            .map(|k| {
503                spread(axis, cone, rays, k)
504                    .dot(axis)
505                    .clamp(-1.0, 1.0)
506                    .acos()
507            })
508            .collect();
509        assert!(angles[0].abs() < 1e-12);
510        assert!(angles.iter().all(|&a| a <= cone + 1e-12));
511        assert!((angles[rays as usize - 1] - cone).abs() < 1e-9);
512    }
513}