parry3d/shape/
triangle_pseudo_normals.rs

1use crate::math::Vector;
2
3#[cfg(feature = "alloc")]
4use crate::{math::Vector3, query::details::NormalConstraints};
5
6// NOTE: ideally, the normal cone should take into account the point where the normal cone is
7//       considered. But as long as we assume that the triangles are one-way we can get away with
8//       just relying on the normal directions.
9//       Taking the point into account would be technically doable (and desirable if we wanted
10//       to define, e.g., a one-way mesh) but requires:
11//       1. To make sure the edge pseudo-normals are given in the correct edge order.
12//       2. To have access to the contact feature.
13//       We can have access to both during the narrow-phase, but leave that as a future
14//       potential improvements.
15// NOTE: this isn’t equal to the "true" normal cones since concave edges will have pseudo-normals
16//       still pointing outward (instead of inward or being empty).
17/// The pseudo-normals of a triangle providing approximations of its feature’s normal cones.
18#[derive(Clone, Debug)]
19pub struct TrianglePseudoNormals {
20    /// The triangle’s face normal.
21    pub face: Vector,
22    // TODO: if we switch this to providing pseudo-normals in a specific order
23    //       (e.g. in the same order as the triangle’s edges), then we should
24    //       think of fixing that order in the heightfield
25    //       triangle_pseudo_normals code.
26    /// The edges pseudo-normals, in no particular order.
27    pub edges: [Vector; 3],
28}
29
30#[cfg(feature = "alloc")]
31impl NormalConstraints for TrianglePseudoNormals {
32    /// Projects the given direction to it is contained in the polygonal
33    /// cone defined `self`.
34    fn project_local_normal_mut(&self, dir: &mut Vector) -> bool {
35        let dot_face = dir.dot(self.face);
36
37        // Find the closest pseudo-normal.
38        let dots = Vector3::new(
39            dir.dot(self.edges[0]),
40            dir.dot(self.edges[1]),
41            dir.dot(self.edges[2]),
42        );
43        let closest_dot = dots.max_position();
44        let closest_edge = self.edges[closest_dot];
45
46        // Apply the projection. Note that this isn’t 100% accurate since this approximates the
47        // vertex normal cone using the closest edge’s normal cone instead of the
48        // true polygonal cone on S² (but taking into account this polygonal cone exactly
49        // would be quite computationally expensive).
50
51        if closest_edge == self.face {
52            // The normal cone is degenerate, there is only one possible direction.
53            *dir = self.face;
54            return dot_face >= 0.0;
55        }
56
57        // TODO: take into account the two closest edges instead for better continuity
58        //       of vertex normals?
59        let dot_edge_face = self.face.dot(closest_edge);
60        let dot_dir_face = self.face.dot(*dir);
61        let dot_corrected_dir_face = 2.0 * dot_edge_face * dot_edge_face - 1.0; // cos(2 * angle(closest_edge, face))
62
63        if dot_dir_face >= dot_corrected_dir_face {
64            // The direction is in the pseudo-normal cone. No correction to apply.
65            return true;
66        }
67
68        // We need to correct.
69        let edge_on_normal = self.face * dot_edge_face;
70        let edge_orthogonal_to_normal = closest_edge - edge_on_normal;
71
72        let dir_on_normal = self.face * dot_dir_face;
73        let dir_orthogonal_to_normal = *dir - dir_on_normal;
74        let Some(unit_dir_orthogonal_to_normal) = dir_orthogonal_to_normal.try_normalize() else {
75            return dot_face >= 0.0;
76        };
77
78        // NOTE: the normalization might be redundant as the result vector is guaranteed to be
79        //       unit sized. Though some rounding errors could throw it off.
80        let adjusted_pseudo_normal =
81            edge_on_normal + unit_dir_orthogonal_to_normal * edge_orthogonal_to_normal.length();
82        let (adjusted_pseudo_normal, length) = adjusted_pseudo_normal.normalize_and_length();
83        if length <= 1.0e-6 {
84            return dot_face >= 0.0;
85        };
86
87        // The reflection of the face normal wrt. the adjusted pseudo-normal gives us the
88        // second end of the pseudo-normal cone the direction is projected on.
89        *dir = adjusted_pseudo_normal * (2.0 * self.face.dot(adjusted_pseudo_normal)) - self.face;
90        dot_face >= 0.0
91    }
92}
93
94#[cfg(test)]
95#[cfg(all(feature = "dim3", feature = "alloc"))]
96mod test {
97    use super::NormalConstraints;
98    use crate::math::{Real, Vector};
99    use crate::shape::TrianglePseudoNormals;
100
101    fn bisector(v1: Vector, v2: Vector) -> Vector {
102        (v1 + v2).normalize()
103    }
104
105    fn bisector_y(v: Vector) -> Vector {
106        bisector(v, Vector::Y)
107    }
108
109    #[test]
110    fn trivial_pseudo_normals_projection() {
111        let pn = TrianglePseudoNormals {
112            face: Vector::Y,
113            edges: [Vector::Y; 3],
114        };
115
116        assert_eq!(
117            pn.project_local_normal(Vector::new(1.0, 1.0, 1.0)),
118            Some(Vector::Y)
119        );
120        assert!(pn.project_local_normal(-Vector::Y).is_none());
121    }
122
123    #[test]
124    fn edge_pseudo_normals_projection_strictly_positive() {
125        let bisector = |v1: Vector, v2: Vector| (v1 + v2).normalize();
126        let bisector_y = |v: Vector| bisector(v, Vector::Y);
127
128        // The normal cones for this test will be fully contained in the +Y half-space.
129        let cones_ref_dir = [
130            -Vector::Z,
131            -Vector::X,
132            Vector::new(1.0, 0.0, 1.0).normalize(),
133        ];
134        let cones_ends = cones_ref_dir.map(bisector_y);
135        let cones_axes = cones_ends.map(bisector_y);
136
137        let pn = TrianglePseudoNormals {
138            face: Vector::Y,
139            edges: cones_axes.map(|v| v.normalize()),
140        };
141
142        for i in 0..3 {
143            assert!(pn
144                .project_local_normal(cones_ends[i])
145                .unwrap()
146                .abs_diff_eq(cones_ends[i], 1.0e-5));
147            assert_eq!(pn.project_local_normal(cones_axes[i]), Some(cones_axes[i]));
148
149            // Guaranteed to be inside the normal cone of edge i.
150            let subdivs = 100;
151
152            for k in 1..100 {
153                let v = Vector::Y
154                    .lerp(cones_ends[i], k as Real / (subdivs as Real))
155                    .normalize();
156                assert_eq!(pn.project_local_normal(v).unwrap(), v);
157            }
158
159            // Guaranteed to be outside the normal cone of edge i.
160            for k in 1..subdivs {
161                let v = cones_ref_dir[i]
162                    .lerp(cones_ends[i], k as Real / (subdivs as Real))
163                    .normalize();
164                assert!(pn
165                    .project_local_normal(v)
166                    .unwrap()
167                    .abs_diff_eq(cones_ends[i], 1.0e-5));
168            }
169
170            // Guaranteed to be outside the normal cone, and in the -Y half-space.
171            for k in 1..subdivs {
172                let v = cones_ref_dir[i]
173                    .lerp(-Vector::Y, k as Real / (subdivs as Real))
174                    .normalize();
175                assert!(pn.project_local_normal(v).is_none(),);
176            }
177        }
178    }
179
180    #[test]
181    fn edge_pseudo_normals_projection_negative() {
182        // The normal cones for this test will be fully contained in the +Y half-space.
183        let cones_ref_dir = [
184            -Vector::Z,
185            -Vector::X,
186            Vector::new(1.0, 0.0, 1.0).normalize(),
187        ];
188        let cones_ends = cones_ref_dir.map(|v| bisector(v, -Vector::Y));
189        let cones_axes = [
190            bisector(bisector_y(cones_ref_dir[0]), cones_ref_dir[0]),
191            bisector(bisector_y(cones_ref_dir[1]), cones_ref_dir[1]),
192            bisector(bisector_y(cones_ref_dir[2]), cones_ref_dir[2]),
193        ];
194
195        let pn = TrianglePseudoNormals {
196            face: Vector::Y,
197            edges: cones_axes.map(|v| v.normalize()),
198        };
199
200        for i in 0..3 {
201            assert_eq!(pn.project_local_normal(cones_axes[i]), Some(cones_axes[i]));
202
203            // Guaranteed to be inside the normal cone of edge i.
204            let subdivs = 100;
205
206            for k in 1..subdivs {
207                let v = Vector::Y
208                    .lerp(cones_ends[i], k as Real / (subdivs as Real))
209                    .normalize();
210                assert_eq!(pn.project_local_normal(v).unwrap(), v);
211            }
212
213            // Guaranteed to be outside the normal cone of edge i.
214            // Since it is additionally guaranteed to be in the -Y half-space, we should get None.
215            for k in 1..subdivs {
216                let v = (-Vector::Y)
217                    .lerp(cones_ends[i], k as Real / (subdivs as Real))
218                    .normalize();
219                assert!(pn.project_local_normal(v).is_none());
220            }
221        }
222    }
223}