Skip to main content

parry3d/shape/
compound_pseudo_normals.rs

1use crate::math::{Real, Vector};
2
3#[cfg(feature = "alloc")]
4use crate::query::details::NormalConstraints;
5#[cfg(feature = "alloc")]
6use alloc::vec::Vec;
7
8/// Whether the turn from `from` to `to` is clockwise.
9#[cfg(all(feature = "alloc", feature = "dim2"))]
10pub(crate) fn turns_clockwise(from: Vector, to: Vector) -> bool {
11    from.x * to.y - from.y * to.x < 0.0
12}
13
14/// The directions one boundary edge of a [`Compound`](crate::shape::Compound) part may push along.
15///
16/// Each limit reaches halfway towards the boundary edge next to it along the union's outline, which
17/// may belong to another part where a cut splits the outline at that corner, so the two edges
18/// meeting there split its range between them. A limit stops at `face` where the corner is concave,
19/// which leaves no range to share, or where the surface simply ends against a cut.
20#[cfg(all(feature = "alloc", feature = "dim2"))]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22#[derive(Clone, Copy, Debug)]
23pub struct CompoundEdgeCone {
24    /// The edge's outward normal.
25    pub face: Vector,
26    /// How far the cone reaches at the edge's clockwise end.
27    pub clockwise_limit: Vector,
28    /// How far the cone reaches at the edge's counter-clockwise end.
29    pub counter_clockwise_limit: Vector,
30}
31
32#[cfg(all(feature = "alloc", feature = "dim2"))]
33impl CompoundEdgeCone {
34    fn contains(&self, dir: Vector) -> bool {
35        dir.dot(self.face) > 0.0
36            && !turns_clockwise(self.clockwise_limit, dir)
37            && !turns_clockwise(dir, self.counter_clockwise_limit)
38    }
39
40    /// The direction in this cone closest to `dir`.
41    fn clamped(&self, dir: Vector) -> Vector {
42        if self.contains(dir) {
43            dir
44        } else if dir.dot(self.clockwise_limit) >= dir.dot(self.counter_clockwise_limit) {
45            self.clockwise_limit
46        } else {
47            self.counter_clockwise_limit
48        }
49    }
50}
51
52/// The outward normal cones of one [`Compound`](crate::shape::Compound) part.
53///
54/// Parts of a decomposed outline meet along cut edges that lie inside the union, where nothing can
55/// reach them -- but each part is convex and reports contacts against its own faces regardless, so
56/// a body sliding across a join catches on a face that is not a surface. Dropping the edges a
57/// sibling part covers leaves every part facing the way the union does.
58///
59/// The [`Compound`](crate::shape::Compound) counterpart of
60/// [`SegmentPseudoNormals`](crate::shape::SegmentPseudoNormals) and
61/// [`TrianglePseudoNormals`](crate::shape::TrianglePseudoNormals), which describe a part that is a
62/// single face; a compound part is a whole polygon, so it carries one cone per boundary edge.
63#[cfg(all(feature = "alloc", feature = "dim2"))]
64#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
65#[derive(Clone, Debug, Default)]
66pub struct CompoundPseudoNormals {
67    /// One cone per boundary edge, in the part's local frame. Cut edges are absent.
68    pub boundary_edges: Vec<CompoundEdgeCone>,
69}
70
71#[cfg(all(feature = "alloc", feature = "dim2"))]
72impl NormalConstraints for CompoundPseudoNormals {
73    /// Projects `dir` onto the closest direction the part's outline can face.
74    ///
75    /// Returns `false` when nothing faces that way, so a contact reached from inside the union is
76    /// dropped rather than turned around.
77    fn project_local_normal_mut(&self, dir: &mut Vector) -> bool {
78        let mut nearest: Option<(Vector, Real)> = None;
79
80        for edge in &self.boundary_edges {
81            let candidate = edge.clamped(*dir);
82            let alignment = dir.dot(candidate);
83
84            match nearest {
85                Some((_, best)) if alignment <= best => {}
86                _ => nearest = Some((candidate, alignment)),
87            }
88        }
89
90        match nearest {
91            Some((direction, alignment)) if alignment > 0.0 => {
92                *dir = direction;
93                true
94            }
95            _ => false,
96        }
97    }
98}
99
100/// The directions one boundary face of a [`Compound`](crate::shape::Compound) part may push
101/// along.
102///
103/// Each edge pseudo-normal reaches halfway towards the boundary face across that edge, which may
104/// belong to another part where a cut splits the surface along it. It keeps the face's own normal
105/// where the edge is concave, which leaves no range to share, or where the surface ends against a
106/// cut.
107#[cfg(all(feature = "alloc", feature = "dim3"))]
108#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
109#[derive(Clone, Debug)]
110pub struct CompoundFaceCone {
111    /// The face's outward normal.
112    pub face: Vector,
113    /// The outward pseudo-normal at each edge of the face.
114    pub edge_pseudo_normals: Vec<Vector>,
115}
116
117/// The outward normal cones of one [`Compound`](crate::shape::Compound) part.
118///
119/// The 3D counterpart of the 2D edge cones: faces two parts share are cuts made by a convex
120/// decomposition, interior to the union where nothing can reach them, so they are absent and no
121/// contact normal can be clamped onto one.
122#[cfg(all(feature = "alloc", feature = "dim3"))]
123#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
124#[derive(Clone, Debug, Default)]
125pub struct CompoundPseudoNormals {
126    /// One cone per boundary face of the part, in the part's local frame.
127    pub boundary_faces: Vec<CompoundFaceCone>,
128}
129
130#[cfg(all(feature = "alloc", feature = "dim3"))]
131impl NormalConstraints for CompoundPseudoNormals {
132    /// Projects `dir` into the normal cone of the boundary face it points most directly out of,
133    /// the way [`TrianglePseudoNormals`](crate::shape::TrianglePseudoNormals) does for one
134    /// triangle.
135    ///
136    /// Returns `false` for a part with no boundary face at all: it is buried inside the union,
137    /// where nothing can touch it.
138    fn project_local_normal_mut(&self, dir: &mut Vector) -> bool {
139        let mut best: Option<(&CompoundFaceCone, Real)> = None;
140
141        for cone in &self.boundary_faces {
142            let alignment = dir.dot(cone.face);
143            match best {
144                Some((_, best_alignment)) if alignment <= best_alignment => {}
145                _ => best = Some((cone, alignment)),
146            }
147        }
148
149        let Some((cone, _)) = best else {
150            return false;
151        };
152
153        let closest_edge = cone
154            .edge_pseudo_normals
155            .iter()
156            .copied()
157            .max_by(|a, b| dir.dot(*a).total_cmp(&dir.dot(*b)))
158            .unwrap_or(cone.face);
159        crate::shape::pseudo_normals::project_into_cone(cone.face, closest_edge, dir)
160    }
161}
162
163#[cfg(test)]
164#[cfg(all(feature = "dim2", feature = "alloc"))]
165mod test {
166    use super::{CompoundEdgeCone, CompoundPseudoNormals, NormalConstraints};
167    use crate::math::Vector;
168    use alloc::vec;
169
170    fn cone(face: Vector, clockwise: Vector, counter_clockwise: Vector) -> CompoundEdgeCone {
171        CompoundEdgeCone {
172            face,
173            clockwise_limit: clockwise,
174            counter_clockwise_limit: counter_clockwise,
175        }
176    }
177
178    /// A cone with nowhere to open, as happens where a surface runs into a cut at both ends.
179    fn pinned(face: Vector) -> CompoundEdgeCone {
180        cone(face, face, face)
181    }
182
183    #[test]
184    fn buried_part_rejects_everything() {
185        let normals = CompoundPseudoNormals::default();
186
187        assert!(normals.project_local_normal(Vector::Y).is_none());
188        assert!(normals.project_local_normal(-Vector::Y).is_none());
189    }
190
191    #[test]
192    fn a_direction_already_on_the_outline_is_left_alone() {
193        let normals = CompoundPseudoNormals {
194            boundary_edges: vec![cone(
195                Vector::Y,
196                Vector::new(1.0, 1.0).normalize(),
197                Vector::new(-1.0, 1.0).normalize(),
198            )],
199        };
200
201        assert_eq!(normals.project_local_normal(Vector::Y), Some(Vector::Y));
202
203        let inside_the_cone = Vector::new(0.3, 1.0).normalize();
204        assert_eq!(
205            normals.project_local_normal(inside_the_cone),
206            Some(inside_the_cone)
207        );
208    }
209
210    #[test]
211    fn a_direction_past_the_corner_is_pulled_back_to_it() {
212        let corner = Vector::new(1.0, 1.0).normalize();
213        let normals = CompoundPseudoNormals {
214            boundary_edges: vec![cone(Vector::Y, corner, Vector::new(-1.0, 1.0).normalize())],
215        };
216
217        assert_eq!(normals.project_local_normal(Vector::X), Some(corner));
218    }
219
220    #[test]
221    fn picks_the_edge_the_normal_points_out_of() {
222        let normals = CompoundPseudoNormals {
223            boundary_edges: vec![pinned(Vector::Y), pinned(Vector::X)],
224        };
225
226        assert_eq!(normals.project_local_normal(Vector::Y), Some(Vector::Y));
227        assert_eq!(normals.project_local_normal(Vector::X), Some(Vector::X));
228    }
229
230    /// The case a convex decomposition creates: a wedge whose tip is a boundary edge running into a
231    /// cut. A body driving into the tip reports a normal along the wedge axis, which is not a
232    /// surface direction, and has to be pulled onto the one boundary edge that is.
233    #[test]
234    fn a_direction_reaching_the_part_from_behind_is_dropped() {
235        let normals = CompoundPseudoNormals {
236            boundary_edges: vec![pinned(Vector::Y)],
237        };
238
239        assert!(normals.project_local_normal(-Vector::Y).is_none());
240    }
241
242    #[test]
243    fn clamps_a_wedge_tip_normal_onto_the_boundary_edge() {
244        let ramp = Vector::new(-0.6, 0.8).normalize();
245        let normals = CompoundPseudoNormals {
246            boundary_edges: vec![pinned(ramp)],
247        };
248
249        let clamped = normals.project_local_normal(-Vector::X).unwrap();
250
251        assert!((clamped - ramp).length() < 1.0e-5);
252    }
253}