axiolid_measure/mesh_faces.rs
1//! Faces of a triangle mesh selected by their normal (#417).
2//!
3//! Takeoff quantities need the area of the downward faces (a floor), or of
4//! the faces normal to a wall with their extent along it. A face is selected
5//! when the angle between its normal and a cone's axis is within the cone's
6//! half-angle plus the tolerance's angular slack. The angle is
7//! `atan2(|N x axis|, N . axis)` of the triangle's raw doubled-area vector
8//! `N`, which stays accurate at angles `acos` of a normalised dot product
9//! cannot resolve.
10//!
11//! The mesh must be surface-usable with no degenerate triangle, exactly as
12//! for [`crate::surface_properties`], so a normal is always defined. Areas
13//! are summed in ascending triangle order about the mesh's first vertex, as
14//! `surface_properties` does, so selecting every face reproduces its area bit
15//! for bit.
16
17use core::fmt;
18
19use axiolid_core::{Interval, Tolerance, Vec3};
20use axiolid_mesh::{audit_mesh, MeshHealth, TriangleMeshView};
21
22use crate::mesh::triangle_area;
23
24/// A direction and an angular reach about it.
25///
26/// The axis is stored normalised, so the stored value does not depend on the
27/// length the caller passed.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct NormalCone {
30 axis: Vec3,
31 half_angle: f64,
32}
33
34impl NormalCone {
35 /// A cone about `axis` reaching `half_angle` radians.
36 ///
37 /// # Errors
38 /// [`FaceSelectionError::InvalidAxis`] when `axis` is not finite or has
39 /// no direction; [`FaceSelectionError::InvalidHalfAngle`] when
40 /// `half_angle` is not finite or outside `[0, pi]`.
41 pub fn new(axis: Vec3, half_angle: f64) -> Result<Self, FaceSelectionError> {
42 let axis = unit(axis).ok_or(FaceSelectionError::InvalidAxis)?;
43 if !(0.0..=core::f64::consts::PI).contains(&half_angle) {
44 return Err(FaceSelectionError::InvalidHalfAngle);
45 }
46 Ok(Self { axis, half_angle })
47 }
48
49 /// The unit axis.
50 pub fn axis(self) -> Vec3 {
51 self.axis
52 }
53
54 /// The half-angle in radians.
55 pub fn half_angle(self) -> f64 {
56 self.half_angle
57 }
58}
59
60/// Triangles chosen by [`select_by_normal`].
61#[non_exhaustive]
62#[derive(Debug, Clone, PartialEq)]
63pub struct FaceSelection {
64 /// Selected triangle indices, ascending.
65 pub triangles: Vec<usize>,
66 /// Total area of the selected triangles; `0.0` when none is selected.
67 pub area: f64,
68}
69
70/// Why a selection or an extent was refused.
71#[non_exhaustive]
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum FaceSelectionError {
74 /// The cone axis is not finite or has no direction.
75 InvalidAxis,
76 /// The half-angle is not finite or outside `[0, pi]`.
77 InvalidHalfAngle,
78 /// The direction to measure along is not finite or has no direction.
79 InvalidDirection,
80 /// The mesh is not surface-usable or has a degenerate triangle.
81 MeshNotSurfaceUsable(MeshHealth),
82 /// A listed triangle does not exist.
83 TriangleOutOfRange {
84 /// The listed index.
85 triangle: usize,
86 },
87 /// A corner of a triangle does not address a position.
88 IndexOutOfRange {
89 /// The triangle holding the corner.
90 triangle: usize,
91 },
92 /// A triangle's normal or area overflows. (A total of finite areas cannot:
93 /// a triangle's area is below `1e154`.)
94 NonFiniteNormal {
95 /// The triangle concerned.
96 triangle: usize,
97 },
98 /// A listed triangle has a non-finite position.
99 NonFinitePosition {
100 /// The triangle concerned.
101 triangle: usize,
102 },
103}
104
105impl fmt::Display for FaceSelectionError {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(match self {
108 Self::InvalidAxis => "cone axis is not a finite direction",
109 Self::InvalidHalfAngle => "cone half-angle is not finite within [0, pi]",
110 Self::InvalidDirection => "direction is not a finite direction",
111 Self::MeshNotSurfaceUsable(_) => "mesh is not suitable for face selection",
112 Self::TriangleOutOfRange { .. } => "triangle index is out of range",
113 Self::IndexOutOfRange { .. } => "triangle corner does not address a position",
114 Self::NonFiniteNormal { .. } => "triangle normal or area is not finite",
115 Self::NonFinitePosition { .. } => "triangle has a non-finite position",
116 })
117 }
118}
119
120impl std::error::Error for FaceSelectionError {}
121
122/// `direction` as a unit vector. It is scaled by its largest component first,
123/// so squaring neither underflows (`1e-170`) nor overflows (`1e200`).
124fn unit(direction: Vec3) -> Option<Vec3> {
125 let largest = direction
126 .x
127 .abs()
128 .max(direction.y.abs())
129 .max(direction.z.abs());
130 if !largest.is_finite() || largest == 0.0 {
131 return None;
132 }
133 let scaled = direction / largest;
134 let unit = scaled / scaled.length();
135 unit.is_finite().then_some(unit)
136}
137
138/// Select the triangles whose normal lies within `cone`.
139///
140/// `tolerance.linear()` decides degenerate triangles as
141/// [`crate::surface_properties`] does (any degenerate triangle refuses the
142/// mesh); `tolerance.angular()` is slack added to the half-angle. A triangle
143/// is selected when `atan2(|N x axis|, N . axis) <= half_angle + angular`.
144/// A floor tilted by `1e-12` is therefore selected at half-angle zero with
145/// [`Tolerance::METRE`] but not with a zero angular tolerance, and an exactly
146/// level floor is selected either way. A half-angle of pi selects every
147/// triangle.
148///
149/// # Errors
150/// [`FaceSelectionError::MeshNotSurfaceUsable`] when the audit finds the mesh
151/// unusable or degenerate, [`FaceSelectionError::NonFiniteNormal`] when a
152/// triangle's normal or area is not finite.
153pub fn select_by_normal<M: TriangleMeshView + ?Sized>(
154 mesh: &M,
155 cone: NormalCone,
156 tolerance: Tolerance,
157) -> Result<FaceSelection, FaceSelectionError> {
158 let health = audit_mesh(mesh, tolerance);
159 if !health.is_surface_usable() || health.degenerate_triangles != 0 {
160 return Err(FaceSelectionError::MeshNotSurfaceUsable(health));
161 }
162 let reach = cone.half_angle + tolerance.angular();
163 let base = mesh.position(0);
164 let (mut triangles, mut area) = (Vec::new(), 0.0f64);
165 for i in 0..mesh.triangle_count() {
166 let [a, b, c] = mesh.triangle(i).map(|j| mesh.position(j as usize) - base);
167 let normal = (b - a).cross(c - a);
168 // Decided before the angle: an overflowed normal or area would make
169 // the angle wrong, not merely non-finite.
170 let weight = triangle_area([a, b, c]);
171 if !normal.is_finite() || !weight.is_finite() {
172 return Err(FaceSelectionError::NonFiniteNormal { triangle: i });
173 }
174 let angle = normal
175 .cross(cone.axis)
176 .length()
177 .atan2(normal.dot(cone.axis));
178 if angle <= reach {
179 triangles.push(i);
180 area += weight;
181 }
182 }
183 Ok(FaceSelection { triangles, area })
184}
185
186/// Extent of the listed triangles along `along`: the least and greatest of
187/// `p . u` over every corner, `u = along / |along|`, as `start` and `end`.
188///
189/// Duplicate and unordered triangle indices are fine. An empty list gives
190/// `None`. The triangles need not form a surface; only their corners are read.
191///
192/// # Errors
193/// [`FaceSelectionError::InvalidDirection`] for an unusable `along`;
194/// [`FaceSelectionError::TriangleOutOfRange`],
195/// [`FaceSelectionError::IndexOutOfRange`] or
196/// [`FaceSelectionError::NonFinitePosition`] naming the first offending
197/// listed triangle.
198pub fn extent_along<M: TriangleMeshView + ?Sized>(
199 mesh: &M,
200 triangles: &[usize],
201 along: Vec3,
202) -> Result<Option<Interval>, FaceSelectionError> {
203 let direction = unit(along).ok_or(FaceSelectionError::InvalidDirection)?;
204 let mut extent: Option<(f64, f64)> = None;
205 for &triangle in triangles {
206 if triangle >= mesh.triangle_count() {
207 return Err(FaceSelectionError::TriangleOutOfRange { triangle });
208 }
209 for corner in mesh.triangle(triangle) {
210 let index = usize::try_from(corner)
211 .ok()
212 .filter(|&index| index < mesh.position_count())
213 .ok_or(FaceSelectionError::IndexOutOfRange { triangle })?;
214 let position = mesh.position(index);
215 let height = position.dot(direction);
216 if !position.is_finite() || !height.is_finite() {
217 return Err(FaceSelectionError::NonFinitePosition { triangle });
218 }
219 extent = Some(match extent {
220 Some((low, high)) => (low.min(height), high.max(height)),
221 None => (height, height),
222 });
223 }
224 }
225 Ok(extent.map(|(low, high)| Interval::new(low, high)))
226}