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}