parry3d/shape/compound.rs
1//!
2//! Shape composed from the union of primitives.
3//!
4
5use crate::bounding_volume::{Aabb, BoundingSphere, BoundingVolume};
6use crate::math::Pose;
7use crate::math::{Real, Vector};
8use crate::partitioning::{Bvh, BvhBuildStrategy};
9use crate::query::details::NormalConstraints;
10#[cfg(feature = "dim2")]
11use crate::shape::compound_pseudo_normals::turns_clockwise;
12#[cfg(feature = "dim2")]
13use crate::shape::CompoundEdgeCone;
14#[cfg(feature = "dim3")]
15use crate::shape::CompoundFaceCone;
16use crate::shape::CompoundPseudoNormals;
17use crate::shape::{CompositeShape, Shape, SharedShape, TypedCompositeShape};
18#[cfg(feature = "dim2")]
19use crate::shape::{ConvexPolygon, TriMesh, Triangle};
20#[cfg(feature = "dim2")]
21use crate::transformation::hertel_mehlhorn;
22use crate::utils::hashmap::HashMap;
23use alloc::vec::Vec;
24
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26#[repr(C)]
27#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
28/// Controls how a [`Compound`] is loaded.
29pub struct CompoundFlags(u8);
30
31bitflags::bitflags! {
32 impl CompoundFlags: u8 {
33 /// If set, the edges (2D) or faces (3D) where two parts meet are treated as interior to
34 /// the union, and contact normals are clamped to the surviving outline. This removes the
35 /// ledge a body catches on when it slides across the cut between two parts of a convex
36 /// decomposition.
37 ///
38 /// This is the [`Compound`] counterpart of `TriMeshFlags::FIX_INTERNAL_EDGES`, and like it
39 /// costs a one-off pass over the parts' edges when set.
40 const FIX_INTERNAL_EDGES = 1;
41 }
42}
43
44/// A compound shape with an aabb bounding volume.
45///
46/// A compound shape is a shape composed of the union of several simpler shape. This is
47/// the main way of creating a concave shape from convex parts. Each parts can have its own
48/// delta transformation to shift or rotate it with regard to the other shapes.
49#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
50#[derive(Clone, Debug)]
51pub struct Compound {
52 shapes: Vec<(Pose, SharedShape)>,
53 bvh: Bvh,
54 aabbs: Vec<Aabb>,
55 aabb: Aabb,
56 #[cfg_attr(feature = "serde", serde(default))]
57 flags: CompoundFlags,
58 /// `None` for a part that constrains nothing: one that is not polygonal, or any part at all
59 /// while [`CompoundFlags::FIX_INTERNAL_EDGES`] is unset.
60 #[cfg_attr(feature = "serde", serde(default))]
61 pseudo_normals: Option<Vec<Option<CompoundPseudoNormals>>>,
62}
63
64impl Compound {
65 /// The tolerance [`Compound::set_flags`] welds part corners with, in ULPs.
66 ///
67 /// Two spellings of the same corner drift by about one ULP per rounding, and a corner reaches
68 /// the surface through a rotation and a translation, so a handful of ULPs covers the seams a
69 /// shared grid produces while staying far below any real feature.
70 pub const DEFAULT_WELD_TOLERANCE: Real = 4.0;
71
72 /// Builds a new compound shape from a collection of sub-shapes.
73 ///
74 /// A compound shape combines multiple primitive shapes into a single composite shape,
75 /// each with its own relative position and orientation. This is the primary way to
76 /// create concave shapes from convex primitives. The compound internally builds a
77 /// Bounding Volume Hierarchy (BVH) for efficient collision detection.
78 ///
79 /// # Arguments
80 ///
81 /// * `shapes` - A vector of (position, shape) pairs. Each pair defines:
82 /// - An [`Pose`] representing the sub-shape's position and orientation relative to the compound's origin
83 /// - A [`SharedShape`] containing the actual geometry
84 ///
85 /// # Panics
86 ///
87 /// - If the input vector is empty (a compound must contain at least one shape)
88 /// - If any of the provided shapes are themselves composite shapes (nested composites are not allowed)
89 ///
90 /// # Performance
91 ///
92 /// The BVH is built using a binned construction strategy optimized for static scenes.
93 /// For large compounds (100+ shapes), construction may take noticeable time but provides
94 /// excellent query performance.
95 ///
96 /// # Example
97 ///
98 /// ```
99 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
100 /// # use parry3d::shape::{Compound, Ball, Cuboid, SharedShape};
101 /// use parry3d::math::Pose;
102 /// use parry3d::math::Vector;
103 ///
104 /// // Create a compound shape resembling a dumbbell
105 /// let shapes = vec![
106 /// // Left sphere
107 /// (
108 /// Pose::translation(-2.0, 0.0, 0.0),
109 /// SharedShape::new(Ball::new(0.5))
110 /// ),
111 /// // Center bar
112 /// (
113 /// Pose::identity(),
114 /// SharedShape::new(Cuboid::new(Vector::new(2.0, 0.2, 0.2)))
115 /// ),
116 /// // Right sphere
117 /// (
118 /// Pose::translation(2.0, 0.0, 0.0),
119 /// SharedShape::new(Ball::new(0.5))
120 /// ),
121 /// ];
122 ///
123 /// let compound = Compound::new(shapes);
124 ///
125 /// // The compound now contains all three shapes
126 /// assert_eq!(compound.shapes().len(), 3);
127 /// # }
128 /// ```
129 ///
130 /// ```
131 /// # #[cfg(all(feature = "dim2", feature = "f32"))] {
132 /// # use parry2d::shape::{Compound, Ball, Cuboid, SharedShape};
133 /// # use parry2d::math::Pose;
134 /// # use parry2d::math::Vector;
135 ///
136 /// // Create an L-shaped compound
137 /// let shapes = vec![
138 /// // Vertical rectangle
139 /// (
140 /// Pose::translation(0.0, 1.0),
141 /// SharedShape::new(Cuboid::new(Vector::new(0.5, 1.0)))
142 /// ),
143 /// // Horizontal rectangle
144 /// (
145 /// Pose::translation(1.0, 0.0),
146 /// SharedShape::new(Cuboid::new(Vector::new(1.0, 0.5)))
147 /// ),
148 /// ];
149 ///
150 /// let l_shape = Compound::new(shapes);
151 /// assert_eq!(l_shape.shapes().len(), 2);
152 /// # }
153 /// ```
154 pub fn new(shapes: Vec<(Pose, SharedShape)>) -> Compound {
155 assert!(
156 !shapes.is_empty(),
157 "A compound shape must contain at least one shape."
158 );
159 let mut aabbs = Vec::new();
160 let mut leaves = Vec::new();
161 let mut aabb = Aabb::new_invalid();
162
163 for (i, (delta, shape)) in shapes.iter().enumerate() {
164 let bv = shape.compute_aabb(delta);
165
166 aabb.merge(&bv);
167 aabbs.push(bv);
168 leaves.push((i, bv));
169
170 if shape.as_composite_shape().is_some() {
171 panic!("Nested composite shapes are not allowed.");
172 }
173 }
174
175 // NOTE: we apply no dilation factor because we won't
176 // update this tree dynamically.
177 let bvh = Bvh::from_iter(BvhBuildStrategy::Binned, leaves);
178
179 Compound {
180 shapes,
181 bvh,
182 aabbs,
183 aabb,
184 flags: CompoundFlags::empty(),
185 pseudo_normals: None,
186 }
187 }
188
189 /// Builds a new compound shape from a collection of sub-shapes, with the given
190 /// [`CompoundFlags`] already applied.
191 ///
192 /// `weld_tolerance` is [`Compound::set_flags`]'s, and `None` selects
193 /// [`Compound::DEFAULT_WELD_TOLERANCE`].
194 ///
195 /// # Example
196 ///
197 /// ```rust
198 /// # #[cfg(all(feature = "dim2", feature = "f32"))] {
199 /// use parry2d::math::{Pose, Vector};
200 /// use parry2d::shape::{Compound, CompoundFlags, Cuboid, SharedShape};
201 ///
202 /// // Two boxes meeting along one edge, as a decomposition or a voxel grid leaves them.
203 /// let box_at = |x: f32| {
204 /// (
205 /// Pose::from_parts(Vector::new(x, 0.0), Default::default()),
206 /// SharedShape::new(Cuboid::new(Vector::new(0.5, 0.5))),
207 /// )
208 /// };
209 /// let compound = Compound::with_flags(
210 /// vec![box_at(0.0), box_at(1.0)],
211 /// CompoundFlags::FIX_INTERNAL_EDGES,
212 /// None,
213 /// );
214 ///
215 /// // The edge they share is interior to the union, so neither part reports it.
216 /// assert_eq!(compound.part_normal_constraints(0).unwrap().boundary_edges.len(), 3);
217 /// # }
218 /// ```
219 pub fn with_flags(
220 shapes: Vec<(Pose, SharedShape)>,
221 flags: CompoundFlags,
222 weld_tolerance: Option<Real>,
223 ) -> Compound {
224 let mut compound = Self::new(shapes);
225 compound.set_flags(flags, weld_tolerance);
226 compound
227 }
228
229 /// Sets the [`CompoundFlags`], computing or discarding the compound's optional associated data.
230 ///
231 /// `weld_tolerance` says how far apart two part corners may be and still be treated as the same
232 /// point, in ULPs; `None` selects [`Compound::DEFAULT_WELD_TOLERANCE`].
233 ///
234 /// Parts of a decomposition meet on corners each computes for itself, and two spellings of the
235 /// same corner need not round to the same bits: a row of boxes on a shared grid, or any part
236 /// placed through a rotation, routinely leaves its seams a few ULPs apart. Corners that far
237 /// apart are welded so the edge or face between them is still recognized as shared.
238 ///
239 /// The tolerance is relative to each corner's own distance from the origin, not the whole
240 /// compound's, so detail near the origin keeps a tolerance of its own magnitude however far the
241 /// rest of the compound reaches. Zero leaves only corners at the very same coordinates welded.
242 ///
243 /// Only [`CompoundFlags::FIX_INTERNAL_EDGES`] reads the tolerance; without it nothing is
244 /// computed and the value is ignored.
245 pub fn set_flags(&mut self, flags: CompoundFlags, weld_tolerance: Option<Real>) {
246 self.flags = flags;
247
248 if flags.contains(CompoundFlags::FIX_INTERNAL_EDGES) {
249 self.compute_pseudo_normals(weld_tolerance.unwrap_or(Self::DEFAULT_WELD_TOLERANCE));
250 } else {
251 self.pseudo_normals = None;
252 }
253 }
254
255 /// The [`CompoundFlags`] controlling this compound's optional associated data.
256 pub fn flags(&self) -> CompoundFlags {
257 self.flags
258 }
259
260 /// The parts' outlines in the compound's frame, wound counter-clockwise. `None` for a part
261 /// with no straight sides, which therefore shares none.
262 #[cfg(feature = "dim2")]
263 fn part_outlines(&self) -> Vec<Option<Vec<Vector>>> {
264 self.shapes
265 .iter()
266 .map(|(part_pos, part)| {
267 let corners: Vec<Vector> = if let Some(polygon) = part.as_convex_polygon() {
268 polygon.points().to_vec()
269 } else if let Some(cuboid) = part.as_cuboid() {
270 cuboid.to_polyline().to_vec()
271 } else {
272 let triangle = part.as_triangle()?;
273 alloc::vec![triangle.a, triangle.b, triangle.c]
274 };
275
276 let mut outline: Vec<Vector> = corners
277 .into_iter()
278 .map(|corner| part_pos.transform_point(corner))
279 .collect();
280
281 if signed_area(&outline) < 0.0 {
282 outline.reverse();
283 }
284
285 Some(outline)
286 })
287 .collect()
288 }
289
290 /// Builds the normal cones every part may report contacts in, from the outline of the union
291 /// rather than from each part's own faces.
292 ///
293 /// An edge two parts share is a cut, so it is dropped, and each surviving edge opens towards
294 /// the edge next to it along the union's outline, which a cut may have left in another part.
295 /// That closes the corner where a surface runs into a cut -- the ledge a body would otherwise
296 /// catch on -- without closing one the cut merely passes through.
297 #[cfg(feature = "dim2")]
298 fn compute_pseudo_normals(&mut self, weld_tolerance: Real) {
299 let outlines = self.part_outlines();
300
301 // Weld every part's corners into one vertex numbering, so two parts meeting along an edge
302 // key it on the same pair of vertices even though each rounded its own corners.
303 let corners: Vec<Vector> = outlines.iter().flatten().flatten().copied().collect();
304 let welded = weld_corners(&corners, weld_tolerance);
305
306 let mut vertex_ids: Vec<Option<Vec<u32>>> = Vec::with_capacity(outlines.len());
307 let mut next = 0;
308 for outline in &outlines {
309 match outline {
310 Some(outline) => {
311 vertex_ids.push(Some(welded[next..next + outline.len()].to_vec()));
312 next += outline.len();
313 }
314 None => vertex_ids.push(None),
315 }
316 }
317
318 let edge_key = |a: u32, b: u32| if a <= b { (a, b) } else { (b, a) };
319
320 let mut parts_sharing_edge = HashMap::default();
321 for ids in vertex_ids.iter().flatten() {
322 for (a, b) in outline_edge_ids(ids) {
323 *parts_sharing_edge.entry(edge_key(a, b)).or_insert(0u32) += 1;
324 }
325 }
326
327 // Per part, the outward normal of every outline edge, or `None` where a sibling part covers
328 // it: that edge is a cut, interior to the union.
329 let boundary_normals: Vec<Option<Vec<Option<Vector>>>> = outlines
330 .iter()
331 .zip(vertex_ids.iter())
332 .map(|(outline, ids)| {
333 let (outline, ids) = (outline.as_ref()?, ids.as_ref()?);
334
335 Some(
336 outline_edges(outline)
337 .zip(outline_edge_ids(ids))
338 .map(|((a, b), (id_a, id_b))| {
339 let shared = parts_sharing_edge
340 .get(&edge_key(id_a, id_b))
341 .is_some_and(|parts| *parts > 1);
342
343 (!shared)
344 .then(|| crate::utils::ccw_face_normal([a, b]))
345 .flatten()
346 })
347 .collect(),
348 )
349 })
350 .collect();
351
352 // The union's boundary edge arriving at, and the one leaving, each corner. Gathered across
353 // every part, so a corner a cut splits still finds the neighbour that continues the
354 // outline into the sibling part. `None` marks a corner more than one edge claims, where no
355 // single neighbour can be picked.
356 let mut arriving = HashMap::default();
357 let mut leaving = HashMap::default();
358 for (ids, normals) in vertex_ids.iter().zip(boundary_normals.iter()) {
359 let (Some(ids), Some(normals)) = (ids.as_ref(), normals.as_ref()) else {
360 continue;
361 };
362
363 for ((a, b), normal) in outline_edge_ids(ids).zip(normals.iter()) {
364 let Some(normal) = *normal else {
365 continue;
366 };
367
368 let _ = arriving
369 .entry(b)
370 .and_modify(|slot| *slot = None)
371 .or_insert(Some(normal));
372 let _ = leaving
373 .entry(a)
374 .and_modify(|slot| *slot = None)
375 .or_insert(Some(normal));
376 }
377 }
378
379 let pseudo_normals = self
380 .shapes
381 .iter()
382 .zip(vertex_ids.iter())
383 .zip(boundary_normals.iter())
384 .map(|(((part_pos, _), ids), normals)| {
385 let (ids, normals) = (ids.as_ref()?, normals.as_ref()?);
386
387 let into_part = part_pos.rotation.inverse();
388 let mut boundary_edges = Vec::new();
389
390 for ((a, b), face) in outline_edge_ids(ids).zip(normals.iter()) {
391 let Some(face) = *face else {
392 continue;
393 };
394
395 // Halfway, so the two edges at a corner split its range and neither cone grows
396 // past a half turn, which the containment test could not represent.
397 let halfway_to = |neighbour: Option<Vector>| match neighbour {
398 Some(neighbour) => (face + neighbour).normalize_or(face),
399 None => face,
400 };
401
402 // A concave corner has no outward range for its two edges to share, so the cone
403 // stops on this edge's own normal, as it does where the surface runs into a cut.
404 // Walking counter-clockwise turns the normals the same way, so the edge arriving
405 // at a corner lies clockwise of the one leaving it.
406 let clockwise = arriving
407 .get(&a)
408 .copied()
409 .flatten()
410 .filter(|previous| !turns_clockwise(*previous, face));
411 let counter_clockwise = leaving
412 .get(&b)
413 .copied()
414 .flatten()
415 .filter(|next| !turns_clockwise(face, *next));
416
417 // Cones are stated in the part's frame, which is where the narrow phase applies
418 // them.
419 boundary_edges.push(CompoundEdgeCone {
420 face: into_part * face,
421 clockwise_limit: into_part * halfway_to(clockwise),
422 counter_clockwise_limit: into_part * halfway_to(counter_clockwise),
423 });
424 }
425
426 Some(CompoundPseudoNormals { boundary_edges })
427 })
428 .collect();
429
430 self.pseudo_normals = Some(pseudo_normals);
431 }
432
433 /// The parts' faces in the compound's frame, each an outward normal with its vertex loop wound
434 /// counter-clockwise around it. `None` for a part with no flat faces, which therefore shares
435 /// none.
436 #[cfg(feature = "dim3")]
437 #[allow(clippy::type_complexity)]
438 fn part_faces(&self) -> Vec<Option<Vec<(Vector, Vec<Vector>)>>> {
439 self.shapes
440 .iter()
441 .map(|(part_pos, part)| {
442 let faces = if let Some(polyhedron) = part.as_convex_polyhedron() {
443 let points = polyhedron.points();
444 let corner_indices = polyhedron.vertices_adj_to_face();
445
446 polyhedron
447 .faces()
448 .iter()
449 .map(|face| {
450 let first = face.first_vertex_or_edge as usize;
451 let corners = corner_indices
452 [first..first + face.num_vertices_or_edges as usize]
453 .iter()
454 .map(|index| points[*index as usize])
455 .collect();
456 (face.normal, corners)
457 })
458 .collect()
459 } else {
460 // A cuboid is a polyhedron the type system does not call one, and a row of them
461 // is how a voxel grid reaches a compound.
462 cuboid_faces(part.as_cuboid()?.half_extents)
463 };
464
465 Some(
466 faces
467 .into_iter()
468 .map(|(normal, corners): (Vector, Vec<Vector>)| {
469 (
470 part_pos.rotation * normal,
471 corners
472 .into_iter()
473 .map(|corner| part_pos.transform_point(corner))
474 .collect(),
475 )
476 })
477 .collect(),
478 )
479 })
480 .collect()
481 }
482
483 /// Builds the normal cones every part may report contacts in, from the surface of the union
484 /// rather than from each part's own faces.
485 ///
486 /// A face two parts share is a cut, so it is dropped, and each edge of a surviving face opens
487 /// towards the boundary face across it, which a cut may have left in another part. That closes
488 /// the corner where a surface runs into a cut -- the ledge a body would otherwise catch on --
489 /// without closing one the cut merely passes through.
490 #[cfg(feature = "dim3")]
491 fn compute_pseudo_normals(&mut self, weld_tolerance: Real) {
492 let part_faces = self.part_faces();
493
494 // Weld every part's corners into one vertex numbering, so two parts meeting along a face
495 // key it on the same set of vertices even though each rounded its own corners.
496 let corners: Vec<Vector> = part_faces
497 .iter()
498 .flatten()
499 .flatten()
500 .flat_map(|(_, corners)| corners.iter().copied())
501 .collect();
502 let welded = weld_corners(&corners, weld_tolerance);
503
504 let mut vertex_ids: Vec<Option<Vec<Vec<u32>>>> = Vec::with_capacity(part_faces.len());
505 let mut next = 0;
506 for faces in &part_faces {
507 match faces {
508 Some(faces) => {
509 let mut per_face = Vec::with_capacity(faces.len());
510 for (_, corners) in faces {
511 per_face.push(welded[next..next + corners.len()].to_vec());
512 next += corners.len();
513 }
514 vertex_ids.push(Some(per_face));
515 }
516 None => vertex_ids.push(None),
517 }
518 }
519
520 // A shared face has the same vertices on both sides, in some rotation and possibly reversed,
521 // so the key ignores their order.
522 let face_key = |ids: &[u32]| {
523 let mut key = ids.to_vec();
524 key.sort_unstable();
525 key
526 };
527 let edge_key = |a: u32, b: u32| if a <= b { (a, b) } else { (b, a) };
528
529 let mut parts_sharing_face = HashMap::default();
530 for ids in vertex_ids.iter().flatten() {
531 for face in ids {
532 *parts_sharing_face.entry(face_key(face)).or_insert(0u32) += 1;
533 }
534 }
535
536 // Per part, whether each face lies on the union's outline rather than being a cut two
537 // parts share.
538 let is_boundary: Vec<Option<Vec<bool>>> = vertex_ids
539 .iter()
540 .map(|ids| {
541 Some(
542 ids.as_ref()?
543 .iter()
544 .map(|face| {
545 parts_sharing_face
546 .get(&face_key(face))
547 .is_none_or(|parts| *parts <= 1)
548 })
549 .collect(),
550 )
551 })
552 .collect();
553
554 // The boundary faces meeting along each edge. Gathered across every part, so an edge a cut
555 // splits still finds the face continuing the surface into the sibling part.
556 let mut faces_along_edge = HashMap::default();
557 for ((faces, ids), boundary) in part_faces
558 .iter()
559 .zip(vertex_ids.iter())
560 .zip(is_boundary.iter())
561 {
562 let (Some(faces), Some(ids), Some(boundary)) =
563 (faces.as_ref(), ids.as_ref(), boundary.as_ref())
564 else {
565 continue;
566 };
567
568 for (((normal, _), face), boundary) in faces.iter().zip(ids.iter()).zip(boundary.iter())
569 {
570 if !boundary {
571 continue;
572 }
573
574 for i in 0..face.len() {
575 faces_along_edge
576 .entry(edge_key(face[i], face[(i + 1) % face.len()]))
577 .or_insert_with(Vec::new)
578 .push(*normal);
579 }
580 }
581 }
582
583 let pseudo_normals = self
584 .shapes
585 .iter()
586 .zip(part_faces.iter())
587 .zip(vertex_ids.iter())
588 .zip(is_boundary.iter())
589 .map(|((((part_pos, _), faces), ids), boundary)| {
590 let (faces, ids, boundary) = (faces.as_ref()?, ids.as_ref()?, boundary.as_ref()?);
591
592 let into_part = part_pos.rotation.inverse();
593 let mut boundary_faces = Vec::new();
594
595 for (((normal, corners), face), on_boundary) in
596 faces.iter().zip(ids.iter()).zip(boundary.iter())
597 {
598 if !on_boundary {
599 continue;
600 }
601
602 let edge_pseudo_normals = (0..corners.len())
603 .map(|i| {
604 let next = (i + 1) % corners.len();
605 let (from, to) = (corners[i], corners[next]);
606 // Halfway to the boundary face across the edge, which may belong to
607 // another part where a cut splits the surface along it. A concave edge
608 // has no outward range for its two faces to share, so the cone stops on
609 // this face's own normal, as it does at an edge whose other face was
610 // cut away. Faces are wound counter-clockwise around their normal, so
611 // the edge is convex exactly when the two normals turn that way too.
612 let across = faces_along_edge
613 .get(&edge_key(face[i], face[next]))
614 .and_then(|normals| {
615 normals.iter().find(|other| **other != *normal).copied()
616 })
617 .filter(|across| normal.cross(*across).dot(to - from) >= 0.0)
618 .unwrap_or(*normal);
619 into_part * (*normal + across).normalize_or(*normal)
620 })
621 .collect();
622
623 boundary_faces.push(CompoundFaceCone {
624 face: into_part * *normal,
625 edge_pseudo_normals,
626 });
627 }
628
629 Some(CompoundPseudoNormals { boundary_faces })
630 })
631 .collect();
632
633 self.pseudo_normals = Some(pseudo_normals);
634 }
635
636 /// Returns the [`CompoundPseudoNormals`] of the part with index `i`.
637 ///
638 /// `None` unless this compound was given [`CompoundFlags::FIX_INTERNAL_EDGES`] and the part is
639 /// polygonal -- a part with curved sides has no edge that another part could cover, so it
640 /// constrains nothing.
641 pub fn part_normal_constraints(&self, i: u32) -> Option<&CompoundPseudoNormals> {
642 self.pseudo_normals.as_ref()?[i as usize].as_ref()
643 }
644
645 /// Creates a compound shape by decomposing a triangle mesh into convex polygons.
646 ///
647 /// This 2D-only method takes a [`TriMesh`] and merges adjacent triangles into larger
648 /// convex polygons using the Hertel-Mehlhorn algorithm. This is useful for creating
649 /// efficient collision shapes from arbitrary 2D meshes, as the resulting compound
650 /// has fewer sub-shapes than using individual triangles.
651 ///
652 /// The algorithm works by:
653 /// 1. Starting with all triangles from the input mesh
654 /// 2. Merging adjacent triangles if the result is still convex
655 /// 3. Creating a compound from the resulting convex polygons
656 ///
657 /// # Arguments
658 ///
659 /// * `trimesh` - A reference to the triangle mesh to decompose
660 ///
661 /// # Returns
662 ///
663 /// * `Some(Compound)` - A compound shape containing the convex polygons
664 /// * `None` - If any of the created shapes has zero or near-zero area
665 ///
666 /// # Performance
667 ///
668 /// This decomposition is typically much more efficient than using the raw triangle mesh,
669 /// as it reduces the number of shapes from N triangles to potentially N/2 or fewer polygons.
670 ///
671 /// # Example
672 ///
673 /// ```
674 /// # #[cfg(all(feature = "dim2", feature = "f32"))] {
675 /// # use parry2d::shape::{Compound, TriMesh};
676 /// # use parry2d::math::Vector;
677 ///
678 /// // Create a simple square mesh (2 triangles)
679 /// let vertices = vec![
680 /// Vector::ZERO,
681 /// Vector::new(1.0, 0.0),
682 /// Vector::new(1.0, 1.0),
683 /// Vector::new(0.0, 1.0),
684 /// ];
685 ///
686 /// let indices = vec![
687 /// [0, 1, 2], // First triangle
688 /// [0, 2, 3], // Second triangle
689 /// ];
690 ///
691 /// let trimesh = TriMesh::new(vertices, indices).unwrap();
692 ///
693 /// // Decompose into convex polygons
694 /// if let Some(compound) = Compound::decompose_trimesh(&trimesh) {
695 /// // The two triangles should be merged into one or two convex polygons
696 /// assert!(compound.shapes().len() <= 2);
697 /// }
698 /// # }
699 /// ```
700 ///
701 /// # Example: Complex Shape
702 ///
703 /// ```
704 /// # #[cfg(all(feature = "dim2", feature = "f32"))] {
705 /// # use parry2d::shape::{Compound, TriMesh};
706 /// # use parry2d::math::Vector;
707 ///
708 /// // Create an L-shaped mesh
709 /// let vertices = vec![
710 /// Vector::ZERO,
711 /// Vector::new(2.0, 0.0),
712 /// Vector::new(2.0, 1.0),
713 /// Vector::new(1.0, 1.0),
714 /// Vector::new(1.0, 2.0),
715 /// Vector::new(0.0, 2.0),
716 /// ];
717 ///
718 /// let indices = vec![
719 /// [0, 1, 2],
720 /// [0, 2, 3],
721 /// [0, 3, 4],
722 /// [0, 4, 5],
723 /// ];
724 ///
725 /// let trimesh = TriMesh::new(vertices, indices).unwrap();
726 ///
727 /// // Decompose the L-shape into convex polygons
728 /// if let Some(compound) = Compound::decompose_trimesh(&trimesh) {
729 /// // The result will have fewer shapes than the original 4 triangles
730 /// assert!(compound.shapes().len() > 0);
731 /// println!("Decomposed into {} convex polygons", compound.shapes().len());
732 /// }
733 /// # }
734 /// ```
735 #[cfg(feature = "dim2")]
736 pub fn decompose_trimesh(trimesh: &TriMesh) -> Option<Self> {
737 let polygons = hertel_mehlhorn(trimesh.vertices(), trimesh.indices());
738 let shapes: Option<Vec<_>> = polygons
739 .into_iter()
740 .map(|points| {
741 match points.len() {
742 3 => {
743 let triangle = Triangle::new(points[0], points[1], points[2]);
744 Some(SharedShape::new(triangle))
745 }
746 _ => ConvexPolygon::from_convex_polyline(points).map(SharedShape::new),
747 }
748 .map(|shape| (Pose::IDENTITY, shape))
749 })
750 .collect();
751 Some(Self::new(shapes?))
752 }
753}
754
755impl Compound {
756 /// Returns a slice containing all sub-shapes and their positions in this compound.
757 ///
758 /// Each element in the returned slice is a tuple containing:
759 /// - The sub-shape's position and orientation ([`Pose`]) relative to the compound's origin
760 /// - The sub-shape itself ([`SharedShape`])
761 ///
762 /// The order of shapes matches the order they were provided to [`Compound::new`].
763 /// The index of each shape in this slice corresponds to its ID used in other operations
764 /// like BVH traversal.
765 ///
766 /// # Returns
767 ///
768 /// A slice of (isometry, shape) pairs representing all sub-shapes in this compound.
769 ///
770 /// # Example
771 ///
772 /// ```
773 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
774 /// # use parry3d::shape::{Compound, Ball, Cuboid, SharedShape};
775 /// use parry3d::math::Pose;
776 /// use parry3d::math::Vector;
777 ///
778 /// let shapes = vec![
779 /// (Pose::translation(1.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
780 /// (Pose::translation(-1.0, 0.0, 0.0), SharedShape::new(Ball::new(0.3))),
781 /// (Pose::identity(), SharedShape::new(Cuboid::new(Vector::new(0.5, 0.5, 0.5)))),
782 /// ];
783 ///
784 /// let compound = Compound::new(shapes);
785 ///
786 /// // Access all shapes
787 /// assert_eq!(compound.shapes().len(), 3);
788 ///
789 /// // Inspect individual shapes
790 /// for (i, (position, shape)) in compound.shapes().iter().enumerate() {
791 /// println!("Shape {} at position: {:?}", i, position.translation);
792 ///
793 /// // Check if it's a ball
794 /// if let Some(ball) = shape.as_ball() {
795 /// println!(" Ball with radius: {}", ball.radius);
796 /// }
797 /// }
798 /// # }
799 /// ```
800 ///
801 /// # Example: Modifying Sub-Shape Positions
802 ///
803 /// ```
804 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
805 /// # use parry3d::shape::{Compound, Ball, SharedShape};
806 /// use parry3d::math::Pose;
807 ///
808 /// let shapes = vec![
809 /// (Pose::translation(0.0, 0.0, 0.0), SharedShape::new(Ball::new(1.0))),
810 /// (Pose::translation(2.0, 0.0, 0.0), SharedShape::new(Ball::new(1.0))),
811 /// ];
812 ///
813 /// let compound = Compound::new(shapes);
814 ///
815 /// // Note: To modify positions, you need to create a new compound
816 /// let mut new_shapes: Vec<_> = compound.shapes()
817 /// .iter()
818 /// .map(|(pos, shape)| {
819 /// // Shift all shapes up by 1 unit
820 /// let new_pos = pos * Pose::translation(0.0, 1.0, 0.0);
821 /// (new_pos, shape.clone())
822 /// })
823 /// .collect();
824 ///
825 /// let shifted_compound = Compound::new(new_shapes);
826 /// assert_eq!(shifted_compound.shapes().len(), 2);
827 /// # }
828 /// ```
829 #[inline]
830 pub fn shapes(&self) -> &[(Pose, SharedShape)] {
831 &self.shapes[..]
832 }
833
834 /// Returns the Axis-Aligned Bounding Box (AABB) of this compound in local space.
835 ///
836 /// The local AABB is the smallest axis-aligned box that contains all sub-shapes
837 /// in the compound, computed in the compound's local coordinate system. This AABB
838 /// is automatically computed when the compound is created and encompasses all
839 /// sub-shapes at their specified positions.
840 ///
841 /// # Returns
842 ///
843 /// A reference to the [`Aabb`] representing the compound's bounding box in local space.
844 ///
845 /// # Use Cases
846 ///
847 /// - **Broad-phase collision detection**: Quick rejection tests before detailed queries
848 /// - **Spatial partitioning**: Organizing compounds in larger spatial structures
849 /// - **Culling**: Determining if the compound is visible or relevant to a query
850 /// - **Size estimation**: Getting approximate dimensions of the compound
851 ///
852 /// # Example
853 ///
854 /// ```
855 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
856 /// # use parry3d::shape::{Compound, Ball, SharedShape};
857 /// use parry3d::math::Pose;
858 /// use parry3d::math::Vector;
859 ///
860 /// let shapes = vec![
861 /// (Pose::translation(-2.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
862 /// (Pose::translation(2.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
863 /// ];
864 ///
865 /// let compound = Compound::new(shapes);
866 /// let aabb = compound.local_aabb();
867 ///
868 /// // The AABB should contain both balls
869 /// // Left ball extends from -2.5 to -1.5 on X axis
870 /// // Right ball extends from 1.5 to 2.5 on X axis
871 /// assert!(aabb.mins.x <= -2.5);
872 /// assert!(aabb.maxs.x >= 2.5);
873 ///
874 /// // Check if a point is inside the AABB
875 /// assert!(aabb.contains_local_point(Vector::ZERO));
876 /// assert!(!aabb.contains_local_point(Vector::new(10.0, 0.0, 0.0)));
877 /// # }
878 /// ```
879 ///
880 /// # Example: Computing Compound Dimensions
881 ///
882 /// ```
883 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
884 /// # use parry3d::shape::{Compound, Cuboid, SharedShape};
885 /// use parry3d::math::Pose;
886 /// use parry3d::math::Vector;
887 ///
888 /// let shapes = vec![
889 /// (Pose::identity(), SharedShape::new(Cuboid::new(Vector::new(1.0, 1.0, 1.0)))),
890 /// (Pose::translation(3.0, 0.0, 0.0), SharedShape::new(Cuboid::new(Vector::new(0.5, 0.5, 0.5)))),
891 /// ];
892 ///
893 /// let compound = Compound::new(shapes);
894 /// let aabb = compound.local_aabb();
895 ///
896 /// // Calculate the total dimensions
897 /// let dimensions = aabb.maxs - aabb.mins;
898 /// println!("Compound dimensions: {:?}", dimensions);
899 ///
900 /// // The compound extends from -1.0 to 3.5 on the X axis (4.5 units total)
901 /// assert!((dimensions.x - 4.5).abs() < 1e-5);
902 /// # }
903 /// ```
904 #[inline]
905 pub fn local_aabb(&self) -> &Aabb {
906 &self.aabb
907 }
908
909 /// Returns the bounding sphere of this compound in local space.
910 ///
911 /// The bounding sphere is the smallest sphere that contains all sub-shapes in the
912 /// compound. It is computed from the compound's AABB by finding the sphere that
913 /// tightly encloses that box. This provides a simple, rotation-invariant bounding
914 /// volume useful for certain collision detection algorithms.
915 ///
916 /// # Returns
917 ///
918 /// A [`BoundingSphere`] centered in local space that contains the entire compound.
919 ///
920 /// # Performance
921 ///
922 /// This method is very fast as it simply computes the bounding sphere from the
923 /// pre-computed AABB. The bounding sphere is not cached - it's computed on each call.
924 ///
925 /// # Comparison with AABB
926 ///
927 /// - **Bounding Sphere**: Rotation-invariant, simpler intersection tests, but often looser fit
928 /// - **AABB**: Tighter fit for axis-aligned objects, but must be recomputed when rotated
929 ///
930 /// # Example
931 ///
932 /// ```
933 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
934 /// # use parry3d::shape::{Compound, Ball, SharedShape};
935 /// use parry3d::math::Pose;
936 ///
937 /// let shapes = vec![
938 /// (Pose::translation(-1.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
939 /// (Pose::translation(1.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
940 /// ];
941 ///
942 /// let compound = Compound::new(shapes);
943 /// let bounding_sphere = compound.local_bounding_sphere();
944 ///
945 /// // The bounding sphere should contain both balls
946 /// println!("Center: {:?}", bounding_sphere.center());
947 /// println!("Radius: {}", bounding_sphere.radius());
948 ///
949 /// // The center should be near the origin
950 /// assert!(bounding_sphere.center().length() < 0.1);
951 ///
952 /// // The radius should be at least 1.5 (distance to ball edge: 1.0 + 0.5)
953 /// assert!(bounding_sphere.radius() >= 1.5);
954 /// # }
955 /// ```
956 ///
957 /// # Example: Using Bounding Sphere for Quick Rejection
958 ///
959 /// ```
960 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
961 /// # use parry3d::shape::{Compound, Cuboid, SharedShape};
962 /// use parry3d::math::{Pose, Vector};
963 ///
964 /// let shapes = vec![
965 /// (Pose::identity(), SharedShape::new(Cuboid::new(Vector::new(1.0, 1.0, 1.0)))),
966 /// (Pose::translation(2.0, 0.0, 0.0), SharedShape::new(Cuboid::new(Vector::new(0.5, 0.5, 0.5)))),
967 /// ];
968 ///
969 /// let compound = Compound::new(shapes);
970 /// let sphere = compound.local_bounding_sphere();
971 ///
972 /// // Quick test: is a point potentially inside the compound?
973 /// let test_point = Vector::new(5.0, 5.0, 5.0);
974 /// let distance_to_center = (test_point - sphere.center()).length();
975 ///
976 /// if distance_to_center > sphere.radius() {
977 /// println!("Vector is definitely outside the compound");
978 /// assert!(distance_to_center > sphere.radius());
979 /// } else {
980 /// println!("Vector might be inside - need detailed check");
981 /// }
982 /// # }
983 /// ```
984 #[inline]
985 pub fn local_bounding_sphere(&self) -> BoundingSphere {
986 self.aabb.bounding_sphere()
987 }
988
989 /// Returns a slice of AABBs, one for each sub-shape in this compound.
990 ///
991 /// Each AABB in the returned slice corresponds to the bounding box of a sub-shape,
992 /// transformed to the compound's local coordinate system. The AABBs are stored in
993 /// the same order as the shapes returned by [`Compound::shapes`], so index `i` in
994 /// this slice corresponds to shape `i`.
995 ///
996 /// These AABBs are used internally by the BVH for efficient spatial queries and
997 /// collision detection. They are pre-computed during compound construction.
998 ///
999 /// # Returns
1000 ///
1001 /// A slice of [`Aabb`] representing the local-space bounding boxes of each sub-shape.
1002 ///
1003 /// # Use Cases
1004 ///
1005 /// - Inspecting individual sub-shape bounds without accessing the shapes themselves
1006 /// - Custom spatial queries or culling operations
1007 /// - Debugging and visualization of the compound's structure
1008 /// - Understanding the BVH's leaf nodes
1009 ///
1010 /// # Example
1011 ///
1012 /// ```
1013 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
1014 /// # use parry3d::shape::{Compound, Ball, Cuboid, SharedShape};
1015 /// use parry3d::math::Pose;
1016 /// use parry3d::math::Vector;
1017 ///
1018 /// let shapes = vec![
1019 /// (Pose::translation(-2.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1020 /// (Pose::identity(), SharedShape::new(Cuboid::new(Vector::new(1.0, 1.0, 1.0)))),
1021 /// (Pose::translation(3.0, 0.0, 0.0), SharedShape::new(Ball::new(0.3))),
1022 /// ];
1023 ///
1024 /// let compound = Compound::new(shapes);
1025 ///
1026 /// // Get AABBs for all sub-shapes
1027 /// let aabbs = compound.aabbs();
1028 /// assert_eq!(aabbs.len(), 3);
1029 ///
1030 /// // Inspect each AABB
1031 /// for (i, aabb) in aabbs.iter().enumerate() {
1032 /// println!("Shape {} AABB:", i);
1033 /// println!(" Min: {:?}", aabb.mins);
1034 /// println!(" Max: {:?}", aabb.maxs);
1035 ///
1036 /// let center = aabb.center();
1037 /// let extents = aabb.half_extents();
1038 /// println!(" Center: {:?}", center);
1039 /// println!(" Half-extents: {:?}", extents);
1040 /// }
1041 /// # }
1042 /// ```
1043 ///
1044 /// # Example: Finding Sub-Shapes in a Region
1045 ///
1046 /// ```
1047 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
1048 /// # use parry3d::shape::{Compound, Ball, SharedShape};
1049 /// use parry3d::math::Pose;
1050 /// use parry3d::bounding_volume::Aabb;
1051 /// use parry3d::math::Vector;
1052 ///
1053 /// let shapes = vec![
1054 /// (Pose::translation(-5.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1055 /// (Pose::translation(0.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1056 /// (Pose::translation(5.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1057 /// ];
1058 ///
1059 /// let compound = Compound::new(shapes);
1060 ///
1061 /// // Define a query point
1062 /// let query_point = Vector::ZERO;
1063 ///
1064 /// // Find which sub-shapes might contain this point
1065 /// let potentially_containing: Vec<usize> = compound.aabbs()
1066 /// .iter()
1067 /// .enumerate()
1068 /// .filter(|(_, aabb)| aabb.contains_local_point(query_point))
1069 /// .map(|(i, _)| i)
1070 /// .collect();
1071 ///
1072 /// // Only the middle shape (index 1) should contain the origin
1073 /// assert_eq!(potentially_containing.len(), 1);
1074 /// assert_eq!(potentially_containing[0], 1);
1075 /// # }
1076 /// ```
1077 #[inline]
1078 pub fn aabbs(&self) -> &[Aabb] {
1079 &self.aabbs[..]
1080 }
1081
1082 /// Returns the Bounding Volume Hierarchy (BVH) used for efficient spatial queries.
1083 ///
1084 /// The BVH is an acceleration structure that organizes the sub-shapes hierarchically
1085 /// for fast collision detection and spatial queries. It enables logarithmic-time queries
1086 /// instead of linear searches through all sub-shapes. The BVH is automatically built
1087 /// when the compound is created using a binned construction strategy.
1088 ///
1089 /// # Returns
1090 ///
1091 /// A reference to the [`Bvh`] acceleration structure for this compound.
1092 ///
1093 /// # Use Cases
1094 ///
1095 /// - **Custom spatial queries**: Traverse the BVH for specialized collision detection
1096 /// - **Ray casting**: Efficiently find which sub-shapes intersect a ray
1097 /// - **AABB queries**: Find all sub-shapes intersecting a region
1098 /// - **Debugging**: Inspect the BVH structure and quality
1099 /// - **Performance analysis**: Understand query performance characteristics
1100 ///
1101 /// # Performance
1102 ///
1103 /// The BVH provides O(log n) query performance for most spatial operations, where n
1104 /// is the number of sub-shapes. For compounds with many shapes (100+), the BVH
1105 /// provides dramatic speedups compared to naive linear searches.
1106 ///
1107 /// # Example
1108 ///
1109 /// ```
1110 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
1111 /// # use parry3d::shape::{Compound, Ball, SharedShape};
1112 /// use parry3d::math::Pose;
1113 /// use parry3d::bounding_volume::Aabb;
1114 /// use parry3d::math::Vector;
1115 ///
1116 /// let shapes = vec![
1117 /// (Pose::translation(-3.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1118 /// (Pose::translation(0.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1119 /// (Pose::translation(3.0, 0.0, 0.0), SharedShape::new(Ball::new(0.5))),
1120 /// ];
1121 ///
1122 /// let compound = Compound::new(shapes);
1123 /// let bvh = compound.bvh();
1124 ///
1125 /// // The BVH provides efficient hierarchical organization
1126 /// assert_eq!(bvh.leaf_count(), 3);
1127 /// println!("BVH root AABB: {:?}", bvh.root_aabb());
1128 /// # }
1129 /// ```
1130 ///
1131 /// # Example: Accessing BVH for Custom Queries
1132 ///
1133 /// ```
1134 /// # #[cfg(all(feature = "dim3", feature = "f32"))] {
1135 /// # use parry3d::shape::{Compound, Ball, SharedShape};
1136 /// use parry3d::math::Pose;
1137 ///
1138 /// let mut shapes = vec![];
1139 /// for i in 0..10 {
1140 /// let x = i as f32 * 2.0;
1141 /// shapes.push((
1142 /// Pose::translation(x, 0.0, 0.0),
1143 /// SharedShape::new(Ball::new(0.5))
1144 /// ));
1145 /// }
1146 ///
1147 /// let compound = Compound::new(shapes);
1148 /// let bvh = compound.bvh();
1149 ///
1150 /// // The BVH organizes 10 shapes hierarchically
1151 /// assert_eq!(bvh.leaf_count(), 10);
1152 /// assert_eq!(compound.shapes().len(), 10);
1153 ///
1154 /// // Access the root AABB which bounds all shapes
1155 /// let root_aabb = bvh.root_aabb();
1156 /// println!("Root AABB spans from {:?} to {:?}", root_aabb.mins, root_aabb.maxs);
1157 /// # }
1158 /// ```
1159 #[inline]
1160 pub fn bvh(&self) -> &Bvh {
1161 &self.bvh
1162 }
1163}
1164
1165impl CompositeShape for Compound {
1166 #[inline]
1167 fn map_part_at(
1168 &self,
1169 shape_id: u32,
1170 f: &mut dyn FnMut(Option<&Pose>, &dyn Shape, Option<&dyn NormalConstraints>),
1171 ) {
1172 if let Some(shape) = self.shapes.get(shape_id as usize) {
1173 let constraints = self
1174 .part_normal_constraints(shape_id)
1175 .map(|c| c as &dyn NormalConstraints);
1176 f(Some(&shape.0), &*shape.1, constraints)
1177 }
1178 }
1179
1180 #[inline]
1181 fn bvh(&self) -> &Bvh {
1182 &self.bvh
1183 }
1184}
1185
1186/// The six faces of a cuboid in its own frame, each an outward normal with its corner loop wound
1187/// counter-clockwise around it.
1188#[cfg(feature = "dim3")]
1189#[allow(clippy::type_complexity)]
1190fn cuboid_faces(half_extents: Vector) -> Vec<(Vector, Vec<Vector>)> {
1191 let (x, y, z) = (half_extents.x, half_extents.y, half_extents.z);
1192 let corner = |sx: Real, sy: Real, sz: Real| Vector::new(sx * x, sy * y, sz * z);
1193
1194 alloc::vec![
1195 (
1196 Vector::X,
1197 alloc::vec![
1198 corner(1.0, -1.0, -1.0),
1199 corner(1.0, 1.0, -1.0),
1200 corner(1.0, 1.0, 1.0),
1201 corner(1.0, -1.0, 1.0),
1202 ]
1203 ),
1204 (
1205 -Vector::X,
1206 alloc::vec![
1207 corner(-1.0, -1.0, -1.0),
1208 corner(-1.0, -1.0, 1.0),
1209 corner(-1.0, 1.0, 1.0),
1210 corner(-1.0, 1.0, -1.0),
1211 ]
1212 ),
1213 (
1214 Vector::Y,
1215 alloc::vec![
1216 corner(-1.0, 1.0, -1.0),
1217 corner(-1.0, 1.0, 1.0),
1218 corner(1.0, 1.0, 1.0),
1219 corner(1.0, 1.0, -1.0),
1220 ]
1221 ),
1222 (
1223 -Vector::Y,
1224 alloc::vec![
1225 corner(-1.0, -1.0, -1.0),
1226 corner(1.0, -1.0, -1.0),
1227 corner(1.0, -1.0, 1.0),
1228 corner(-1.0, -1.0, 1.0),
1229 ]
1230 ),
1231 (
1232 Vector::Z,
1233 alloc::vec![
1234 corner(-1.0, -1.0, 1.0),
1235 corner(1.0, -1.0, 1.0),
1236 corner(1.0, 1.0, 1.0),
1237 corner(-1.0, 1.0, 1.0),
1238 ]
1239 ),
1240 (
1241 -Vector::Z,
1242 alloc::vec![
1243 corner(-1.0, -1.0, -1.0),
1244 corner(-1.0, 1.0, -1.0),
1245 corner(1.0, 1.0, -1.0),
1246 corner(1.0, -1.0, -1.0),
1247 ]
1248 ),
1249 ]
1250}
1251
1252/// Assigns every corner the index of the vertex it welds onto, so parts that meet are keyed on
1253/// the same point even when each computed it for itself.
1254///
1255/// Two corners weld when no coordinate differs by more than `tolerance` ULPs, measured at the
1256/// scale of whichever of the two lies further from the origin. That scale has to come from the
1257/// point rather than the coordinate: a corner reaching the outline through a rotation can land
1258/// near an axis, leaving a coordinate whose own magnitude is pure cancellation and says nothing
1259/// about the error in it, while its siblings still carry the magnitude the rounding happened at.
1260///
1261/// A tolerance of zero leaves only corners at the very same coordinates welded, matching what a
1262/// shared vertex buffer produces and nothing else. The comparison stays numeric there rather than
1263/// dropping to raw bits, so the two signed zeros still meet.
1264fn weld_corners(corners: &[Vector], tolerance: Real) -> Vec<u32> {
1265 let radius = |corner: &Vector| tolerance * Real::EPSILON * corner.abs().max_element();
1266
1267 // Union-find over the corners, so a chain of near-coincident corners collapses to one vertex
1268 // whichever order the pairs turn up in.
1269 let mut parent: Vec<u32> = (0..corners.len() as u32).collect();
1270 fn find(parent: &mut [u32], mut i: u32) -> u32 {
1271 while parent[i as usize] != i {
1272 parent[i as usize] = parent[parent[i as usize] as usize];
1273 i = parent[i as usize];
1274 }
1275 i
1276 }
1277
1278 let leaves: Vec<Aabb> = corners
1279 .iter()
1280 .map(|corner| Aabb::new(*corner, *corner))
1281 .collect();
1282 let bvh = Bvh::from_leaves(BvhBuildStrategy::Binned, &leaves);
1283
1284 for (i, corner) in corners.iter().enumerate() {
1285 // Querying every corner with its own radius finds every pair that should weld: the test is
1286 // symmetric and scaled by the larger of the two, so the larger corner's own query reaches
1287 // the smaller one even when the smaller one's radius would fall short.
1288 let reach = radius(corner);
1289 let query = Aabb::new(
1290 *corner - Vector::splat(reach),
1291 *corner + Vector::splat(reach),
1292 );
1293
1294 for j in bvh.intersect_aabb(&query) {
1295 let other = corners[j as usize];
1296 let scale = corner.abs().max_element().max(other.abs().max_element());
1297
1298 if (*corner - other).abs().max_element() <= tolerance * Real::EPSILON * scale {
1299 let (a, b) = (find(&mut parent, i as u32), find(&mut parent, j));
1300 parent[a as usize] = b;
1301 }
1302 }
1303 }
1304
1305 // Number the roots so the ids are dense.
1306 let mut ids = HashMap::default();
1307 (0..corners.len() as u32)
1308 .map(|i| {
1309 let root = find(&mut parent, i);
1310 let next = ids.len() as u32;
1311 *ids.entry(root).or_insert(next)
1312 })
1313 .collect()
1314}
1315
1316/// The edges of a closed outline, as consecutive vertex-index pairs wrapping back to the start.
1317#[cfg(feature = "dim2")]
1318fn outline_edge_ids(ids: &[u32]) -> impl Iterator<Item = (u32, u32)> + '_ {
1319 ids.iter()
1320 .enumerate()
1321 .map(move |(i, id)| (*id, ids[(i + 1) % ids.len()]))
1322}
1323
1324/// The edges of a closed outline, as consecutive corner pairs wrapping back to the start.
1325#[cfg(feature = "dim2")]
1326fn outline_edges(outline: &[Vector]) -> impl Iterator<Item = (Vector, Vector)> + '_ {
1327 outline
1328 .iter()
1329 .enumerate()
1330 .map(move |(i, corner)| (*corner, outline[(i + 1) % outline.len()]))
1331}
1332
1333/// Twice the area enclosed by a closed outline, negative when it is wound clockwise.
1334#[cfg(feature = "dim2")]
1335fn signed_area(outline: &[Vector]) -> Real {
1336 outline_edges(outline)
1337 .map(|(a, b)| a.x * b.y - a.y * b.x)
1338 .sum()
1339}
1340
1341impl TypedCompositeShape for Compound {
1342 type PartShape = dyn Shape;
1343 type PartNormalConstraints = CompoundPseudoNormals;
1344
1345 #[inline(always)]
1346 fn map_typed_part_at<T>(
1347 &self,
1348 i: u32,
1349 mut f: impl FnMut(Option<&Pose>, &Self::PartShape, Option<&Self::PartNormalConstraints>) -> T,
1350 ) -> Option<T> {
1351 let (part_pos, part) = &self.shapes[i as usize];
1352 Some(f(Some(part_pos), &**part, self.part_normal_constraints(i)))
1353 }
1354
1355 #[inline(always)]
1356 fn map_untyped_part_at<T>(
1357 &self,
1358 i: u32,
1359 mut f: impl FnMut(Option<&Pose>, &Self::PartShape, Option<&dyn NormalConstraints>) -> T,
1360 ) -> Option<T> {
1361 let (part_pos, part) = &self.shapes[i as usize];
1362 let constraints = self
1363 .part_normal_constraints(i)
1364 .map(|c| c as &dyn NormalConstraints);
1365 Some(f(Some(part_pos), &**part, constraints))
1366 }
1367}
1368
1369#[cfg(test)]
1370#[cfg(all(feature = "dim2", feature = "alloc", feature = "std"))]
1371mod test {
1372 use super::{Compound, CompoundFlags};
1373 use crate::math::{Pose, Real, Rotation, Vector};
1374 use crate::query::{ContactManifold, DefaultQueryDispatcher, PersistentQueryDispatcher};
1375 use crate::shape::{ConvexPolygon, Cuboid, SharedShape, Triangle};
1376 use alloc::{vec, vec::Vec};
1377
1378 /// A flat run that turns into a ramp, decomposed the way a convex decomposition would leave it:
1379 /// a big piece below and a wedge above, meeting along a cut that runs right from the ramp foot
1380 /// at the origin. The ramp face and the cut leave the wedge with a tip pointing back along the
1381 /// flat run, which is what a body slides into.
1382 fn ramp_foot() -> Compound {
1383 let part = |points: &[[Real; 2]]| {
1384 let points = points.iter().map(|p| Vector::new(p[0], p[1])).collect();
1385 (
1386 Pose::IDENTITY,
1387 SharedShape::new(
1388 ConvexPolygon::from_convex_polyline_unmodified(points).expect("convex part"),
1389 ),
1390 )
1391 };
1392
1393 Compound::new(vec![
1394 part(&[
1395 [-3.825, -0.015],
1396 [-11.913, -6.486],
1397 [5.166, -6.486],
1398 [5.165, -0.015],
1399 [0.0, 0.0],
1400 ]),
1401 part(&[[0.0, 0.0], [5.165, -0.015], [3.432, 1.912], [2.135, 1.891]]),
1402 ])
1403 }
1404
1405 /// The normals the wedge reports against a box whose right edge sits `overshoot` past the tip.
1406 fn normals_against_the_wedge(compound: &Compound, overshoot: Real) -> Vec<Vector> {
1407 let slider = Cuboid::new(Vector::new(0.42, 0.20));
1408 let pos12 = Pose::from_parts(Vector::new(overshoot - 0.42, 0.198), Default::default());
1409
1410 let mut manifolds: Vec<ContactManifold<(), ()>> = Vec::new();
1411 let mut workspace = None;
1412 DefaultQueryDispatcher
1413 .contact_manifolds(
1414 &pos12,
1415 compound,
1416 &slider,
1417 0.02,
1418 &mut manifolds,
1419 &mut workspace,
1420 )
1421 .expect("dispatch");
1422
1423 manifolds
1424 .iter()
1425 .filter(|manifold| manifold.subshape1 == 1 && !manifold.points.is_empty())
1426 .map(|manifold| manifold.local_n1)
1427 .collect()
1428 }
1429
1430 #[test]
1431 fn cut_edges_are_dropped_and_close_the_corner_they_end_at() {
1432 let mut compound = ramp_foot();
1433 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1434
1435 // Each part loses exactly the cut it shares with the other.
1436 assert_eq!(
1437 compound
1438 .part_normal_constraints(0)
1439 .unwrap()
1440 .boundary_edges
1441 .len(),
1442 4
1443 );
1444 let wedge = compound.part_normal_constraints(1).unwrap();
1445 assert_eq!(wedge.boundary_edges.len(), 3);
1446
1447 // The ramp face runs into the cut at the tip, so its cone closes onto itself there.
1448 let ramp = wedge
1449 .boundary_edges
1450 .iter()
1451 .find(|cone| cone.face.x < -0.5)
1452 .expect("ramp face");
1453 assert_eq!(ramp.counter_clockwise_limit, ramp.face);
1454 }
1455
1456 /// Nothing guarantees which way a part was wound, and a clockwise one would have every normal
1457 /// pointing into itself.
1458 #[test]
1459 fn a_clockwise_part_still_faces_outwards() {
1460 let clockwise = Triangle::new(
1461 Vector::new(0.0, 0.0),
1462 Vector::new(0.0, 1.0),
1463 Vector::new(1.0, 0.0),
1464 );
1465 let mut compound = Compound::new(vec![(Pose::IDENTITY, SharedShape::new(clockwise))]);
1466 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1467
1468 let cones = compound.part_normal_constraints(0).expect("polygonal part");
1469 assert_eq!(cones.boundary_edges.len(), 3);
1470
1471 // Every face points away from the triangle's interior, which lies towards the origin.
1472 let interior = Vector::new(0.25, 0.25);
1473 for cone in &cones.boundary_edges {
1474 assert!(
1475 cone.face.dot(interior) < 0.0 || cone.face.dot(Vector::new(1.0, 1.0)) > 0.0,
1476 "inward face {:?}",
1477 cone.face
1478 );
1479 }
1480
1481 // The hypotenuse is the only face pointing up and to the right.
1482 let hypotenuse = cones
1483 .boundary_edges
1484 .iter()
1485 .find(|cone| cone.face.x > 0.5 && cone.face.y > 0.5)
1486 .expect("outward hypotenuse");
1487 assert!(hypotenuse.face.dot(Vector::new(1.0, 1.0).normalize()) > 0.99);
1488 }
1489
1490 #[test]
1491 fn a_body_running_into_the_tip_reports_no_contact_with_the_cut() {
1492 let plain = ramp_foot();
1493 let mut fixed = ramp_foot();
1494 fixed.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1495
1496 // Against the bare compound the wedge answers with its tip axis: a wall across the flat run.
1497 let ghost = normals_against_the_wedge(&plain, 0.0);
1498 assert_eq!(ghost.len(), 1);
1499 assert!(
1500 ghost[0].x < -0.99,
1501 "expected a wall normal, got {:?}",
1502 ghost[0]
1503 );
1504
1505 // The cut cannot be touched, so the wedge reports nothing until the ramp itself is reached.
1506 assert!(normals_against_the_wedge(&fixed, 0.0).is_empty());
1507 }
1508
1509 /// An L wound counter-clockwise, split along the diagonal a convex decomposition would cut
1510 /// from its reflex corner. The cut lands on two corners of the union: the convex one at the
1511 /// origin, and the reflex one at `(1, 1)`.
1512 fn cut_ell() -> Compound {
1513 let part = |points: &[[Real; 2]]| {
1514 let points = points.iter().map(|p| Vector::new(p[0], p[1])).collect();
1515 (
1516 Pose::IDENTITY,
1517 SharedShape::new(
1518 ConvexPolygon::from_convex_polyline_unmodified(points).expect("convex part"),
1519 ),
1520 )
1521 };
1522
1523 let mut compound = Compound::new(vec![
1524 part(&[[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]),
1525 part(&[[0.0, 0.0], [1.0, 1.0], [1.0, 2.0], [0.0, 2.0]]),
1526 ]);
1527 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1528 compound
1529 }
1530
1531 /// The cone of the boundary edge of `part` whose outward normal is `face`.
1532 fn cone_facing(compound: &Compound, part: u32, face: Vector) -> super::CompoundEdgeCone {
1533 *compound
1534 .part_normal_constraints(part)
1535 .expect("polygonal part")
1536 .boundary_edges
1537 .iter()
1538 .find(|cone| (cone.face - face).length() < 1.0e-5)
1539 .unwrap_or_else(|| panic!("no edge facing {face:?}"))
1540 }
1541
1542 /// A corner a cut splits is still a corner of the union, so the two edges meeting there open
1543 /// towards each other even though they belong to different parts.
1544 #[test]
1545 fn a_convex_corner_split_by_a_cut_opens_across_the_parts() {
1546 let compound = cut_ell();
1547
1548 // Each part keeps its three outline edges and loses the diagonal it shares.
1549 for part in 0..2 {
1550 assert_eq!(
1551 compound
1552 .part_normal_constraints(part)
1553 .unwrap()
1554 .boundary_edges
1555 .len(),
1556 3,
1557 "part {part}"
1558 );
1559 }
1560
1561 // The union turns 90 degrees at the origin, between the bottom edge of one part and the
1562 // left edge of the other. Both cones reach the bisector, splitting the corner between them.
1563 let bisector = Vector::new(-1.0, -1.0).normalize();
1564 let bottom = cone_facing(&compound, 0, -Vector::Y);
1565 let left = cone_facing(&compound, 1, -Vector::X);
1566
1567 assert!(
1568 (bottom.clockwise_limit - bisector).length() < 1.0e-5,
1569 "bottom stopped at {:?}",
1570 bottom.clockwise_limit
1571 );
1572 assert!(
1573 (left.counter_clockwise_limit - bisector).length() < 1.0e-5,
1574 "left stopped at {:?}",
1575 left.counter_clockwise_limit
1576 );
1577 }
1578
1579 /// A concave corner has no outward range for its edges to share, so each keeps its own normal
1580 /// -- the same thing that happens where a surface runs into a cut.
1581 #[test]
1582 fn a_concave_corner_split_by_a_cut_stays_closed() {
1583 let compound = cut_ell();
1584
1585 // The reflex corner of the L, at `(1, 1)`: the upwards edge of one part meets the
1586 // rightwards edge of the other.
1587 let upwards = cone_facing(&compound, 0, Vector::Y);
1588 let rightwards = cone_facing(&compound, 1, Vector::X);
1589
1590 assert_eq!(upwards.counter_clockwise_limit, upwards.face);
1591 assert_eq!(rightwards.clockwise_limit, rightwards.face);
1592 }
1593
1594 /// A row of boxes on a shared grid, the way a voxel body reaches a compound, optionally
1595 /// rotated as a whole. Every part computes its own corners, so the seams are only found if
1596 /// corners that round differently still weld.
1597 fn voxel_row(n: u32, size: Real, origin: Real, angle: Real) -> Compound {
1598 let half = size / 2.0;
1599 let rotation = Rotation::from_angle(angle);
1600 let parts = (0..n)
1601 .map(|i| {
1602 let center = Vector::new(origin + i as Real * size, 0.0);
1603 (
1604 Pose::from_parts(rotation * center, rotation),
1605 SharedShape::new(Cuboid::new(Vector::new(half, half))),
1606 )
1607 })
1608 .collect();
1609
1610 let mut compound = Compound::new(parts);
1611 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1612 compound
1613 }
1614
1615 /// How many boundary edges each part of a row kept. A welded row leaves the two ends with
1616 /// three and every box between them with two.
1617 fn boundary_edge_counts(compound: &Compound, n: u32) -> Vec<usize> {
1618 (0..n)
1619 .map(|i| {
1620 compound
1621 .part_normal_constraints(i)
1622 .map_or(99, |cones| cones.boundary_edges.len())
1623 })
1624 .collect()
1625 }
1626
1627 /// Parts of a grid meet on corners each computes for itself, and the two spellings need not
1628 /// round alike. Every row here leaves at least one seam a few ULPs wide.
1629 #[test]
1630 fn a_voxel_row_finds_seams_its_parts_rounded_differently() {
1631 for &(size, origin, angle) in &[
1632 (1.0 as Real, 0.0 as Real, 0.1 as Real),
1633 (1.0, 0.0, 0.5236),
1634 (1.0, 0.0, 0.7854),
1635 (0.1, 0.0, 0.0),
1636 (0.7, 0.0, 0.0),
1637 (1.0, 0.05, 0.0),
1638 (0.1, 1000.0, 0.0),
1639 (1.0, 1000.0, 0.7854),
1640 ] {
1641 assert_eq!(
1642 boundary_edge_counts(&voxel_row(5, size, origin, angle), 5),
1643 alloc::vec![3, 2, 2, 2, 3],
1644 "size {size} origin {origin} angle {angle}"
1645 );
1646 }
1647 }
1648
1649 /// A zero tolerance is the exact comparison, which only recognizes a seam whose two spellings
1650 /// round to the very same bits.
1651 #[test]
1652 fn a_zero_tolerance_welds_only_bit_identical_corners() {
1653 let mut rotated = voxel_row(5, 1.0, 0.0, 0.1);
1654 let welded = boundary_edge_counts(&rotated, 5);
1655 assert_eq!(welded, alloc::vec![3, 2, 2, 2, 3]);
1656
1657 // Dropping the tolerance can only lose cuts, never find new ones, and this rotation puts
1658 // at least one seam beyond an exact comparison.
1659 rotated.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, Some(0.0));
1660 let exact = boundary_edge_counts(&rotated, 5);
1661 assert!(
1662 exact.iter().zip(welded.iter()).all(|(a, b)| a >= b),
1663 "exact found a cut the tolerance missed: {exact:?}"
1664 );
1665 assert!(
1666 exact.iter().sum::<usize>() > welded.iter().sum::<usize>(),
1667 "exact matched every seam: {exact:?}"
1668 );
1669
1670 // A grid whose corners land on exact coordinates never needed the tolerance.
1671 let mut exact = voxel_row(5, 1.0, 0.0, 0.0);
1672 exact.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, Some(0.0));
1673 assert_eq!(boundary_edge_counts(&exact, 5), alloc::vec![3, 2, 2, 2, 3]);
1674 }
1675
1676 /// Every interior seam of a full grid is found, and only the perimeter survives: an `n` by `n`
1677 /// grid of cells encloses exactly `4 * n` boundary edges. Four cells meet at every interior
1678 /// corner, so this also exercises welding more than two coincident corners at once.
1679 #[test]
1680 fn a_voxel_grid_keeps_only_its_perimeter() {
1681 for &(n, size, origin, angle) in &[
1682 (3u32, 1.0 as Real, 0.0 as Real, 0.1 as Real),
1683 (3, 0.1, 0.0, 0.0),
1684 (4, 0.7, 5.0, 0.7854),
1685 ] {
1686 let half = size / 2.0;
1687 let rotation = Rotation::from_angle(angle);
1688 let parts = (0..n)
1689 .flat_map(|i| {
1690 (0..n).map(move |j| {
1691 let center =
1692 Vector::new(origin + i as Real * size, origin + j as Real * size);
1693 (
1694 Pose::from_parts(rotation * center, rotation),
1695 SharedShape::new(Cuboid::new(Vector::new(half, half))),
1696 )
1697 })
1698 })
1699 .collect();
1700
1701 let mut compound = Compound::new(parts);
1702 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1703
1704 let counts = boundary_edge_counts(&compound, n * n);
1705 assert_eq!(
1706 counts.iter().sum::<usize>(),
1707 (4 * n) as usize,
1708 "size {size} origin {origin} angle {angle}: {counts:?}"
1709 );
1710 }
1711 }
1712
1713 /// The two signed zeros are the same point and hash apart, so a corner one part spells `0.0`
1714 /// and another `-0.0` has to weld even where no tolerance is allowed at all.
1715 #[test]
1716 fn signed_zero_corners_weld_at_zero_tolerance() {
1717 assert_ne!(
1718 (0.0 as Real).to_bits(),
1719 (-0.0 as Real).to_bits(),
1720 "the two zeros are meant to differ bitwise"
1721 );
1722
1723 // Two triangles sharing the edge from the origin to `(1, 0)`, the second spelling its end
1724 // of that edge with negative zeros.
1725 let mut compound = Compound::new(vec![
1726 (
1727 Pose::IDENTITY,
1728 SharedShape::new(Triangle::new(
1729 Vector::new(0.0, 0.0),
1730 Vector::new(1.0, 0.0),
1731 Vector::new(0.5, 1.0),
1732 )),
1733 ),
1734 (
1735 Pose::IDENTITY,
1736 SharedShape::new(Triangle::new(
1737 Vector::new(-0.0, -0.0),
1738 Vector::new(0.5, -1.0),
1739 Vector::new(1.0, -0.0),
1740 )),
1741 ),
1742 ]);
1743 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, Some(0.0));
1744
1745 assert_eq!(boundary_edge_counts(&compound, 2), alloc::vec![2, 2]);
1746 }
1747
1748 /// The tolerance follows each corner's own distance from the origin rather than the whole
1749 /// compound's, so a part placed far away does not coarsen detail near it.
1750 #[test]
1751 fn a_distant_part_does_not_coarsen_detail_near_the_origin() {
1752 let small = 1.0e-3;
1753 let box_at = |x: Real, half: Real| {
1754 (
1755 Pose::from_parts(Vector::new(x, 0.0), Default::default()),
1756 SharedShape::new(Cuboid::new(Vector::new(half, half))),
1757 )
1758 };
1759
1760 // Two small boxes with a gap between them, and one part 1e5 away. Scaled to the distant
1761 // part the gap would vanish; scaled to the small boxes themselves it is enormous.
1762 let mut compound = Compound::new(vec![
1763 box_at(0.0, small),
1764 box_at(4.0 * small, small),
1765 box_at(1.0e5, 1.0),
1766 ]);
1767 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1768
1769 for part in 0..2 {
1770 assert_eq!(
1771 compound
1772 .part_normal_constraints(part)
1773 .unwrap()
1774 .boundary_edges
1775 .len(),
1776 4,
1777 "part {part} lost an edge to the distant part's scale"
1778 );
1779 }
1780 }
1781
1782 #[test]
1783 fn contacts_with_the_ramp_itself_are_left_alone() {
1784 let plain = ramp_foot();
1785 let mut fixed = ramp_foot();
1786 fixed.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1787
1788 let expected = normals_against_the_wedge(&plain, 0.01);
1789 assert_eq!(expected.len(), 1);
1790 assert!(
1791 expected[0].y > 0.7,
1792 "expected the ramp face, got {:?}",
1793 expected[0]
1794 );
1795 assert_eq!(normals_against_the_wedge(&fixed, 0.01), expected);
1796 }
1797}
1798
1799#[cfg(test)]
1800#[cfg(all(feature = "dim3", feature = "alloc", feature = "std"))]
1801mod dim3_test {
1802 use super::{Compound, CompoundFlags};
1803 use crate::math::{Pose, Real, Rotation, Vector};
1804 use crate::shape::{Cuboid, SharedShape};
1805 use alloc::{vec, vec::Vec};
1806
1807 /// A flat run turning into a ramp, split along the diagonal a convex decomposition would cut:
1808 /// a slab and a wedge, extruded, sharing one quad exactly. Built through `convex_hull`, the
1809 /// way the Godot plugin builds every `ConvexPolygonShape3D`.
1810 fn ramp_foot() -> Compound {
1811 let prism = |outline: &[[Real; 2]]| {
1812 let corners: Vec<Vector> = outline
1813 .iter()
1814 .flat_map(|p| [Vector::new(p[0], p[1], -3.0), Vector::new(p[0], p[1], 3.0)])
1815 .collect();
1816 (
1817 Pose::IDENTITY,
1818 SharedShape::convex_hull(&corners).expect("convex prism"),
1819 )
1820 };
1821
1822 Compound::new(vec![
1823 prism(&[[-6.0, 0.0], [5.0, 0.0], [11.0, -6.0], [-6.0, -6.0]]),
1824 prism(&[[5.0, 0.0], [9.0, 3.0], [11.0, 3.0], [11.0, -6.0]]),
1825 ])
1826 }
1827
1828 #[test]
1829 fn cut_faces_are_dropped_from_both_parts() {
1830 let mut compound = ramp_foot();
1831 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1832
1833 // Each prism has 6 faces and loses the shared quad -- which only holds if `convex_hull`
1834 // produced the cut with the same corners on both sides, the premise everything rests on.
1835 for part in 0..2 {
1836 let cones = compound.part_normal_constraints(part).expect("polyhedron");
1837 assert_eq!(cones.boundary_faces.len(), 5, "part {part}");
1838 }
1839 }
1840
1841 /// The 2D cut L, extruded. The cut is a quad, and it reaches the union's surface along two
1842 /// vertical edges: the convex one at the origin, and the reflex one above `(1, 1)`.
1843 fn cut_ell() -> Compound {
1844 let prism = |outline: &[[Real; 2]]| {
1845 let corners: Vec<Vector> = outline
1846 .iter()
1847 .flat_map(|p| [Vector::new(p[0], p[1], -1.0), Vector::new(p[0], p[1], 1.0)])
1848 .collect();
1849 (
1850 Pose::IDENTITY,
1851 SharedShape::convex_hull(&corners).expect("convex prism"),
1852 )
1853 };
1854
1855 let mut compound = Compound::new(vec![
1856 prism(&[[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]),
1857 prism(&[[0.0, 0.0], [1.0, 1.0], [1.0, 2.0], [0.0, 2.0]]),
1858 ]);
1859 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1860 compound
1861 }
1862
1863 /// The pseudo-normal the cone of `part`'s face pointing along `face` carries at the vertical
1864 /// edge standing over `corner`.
1865 fn pseudo_normal_at_vertical_edge(
1866 compound: &Compound,
1867 part: u32,
1868 face: Vector,
1869 corner: [Real; 2],
1870 ) -> Vector {
1871 let (bottom, top) = (
1872 Vector::new(corner[0], corner[1], -1.0),
1873 Vector::new(corner[0], corner[1], 1.0),
1874 );
1875
1876 let faces = compound.part_faces();
1877 let (_, corners) = faces[part as usize]
1878 .as_ref()
1879 .expect("polyhedron")
1880 .iter()
1881 .find(|(normal, _)| (*normal - face).length() < 1.0e-5)
1882 .unwrap_or_else(|| panic!("no face pointing along {face:?}"));
1883
1884 // `edge_pseudo_normals[i]` describes the edge from `corners[i]` to its successor.
1885 let edge = (0..corners.len())
1886 .find(|i| {
1887 let ends = [corners[*i], corners[(*i + 1) % corners.len()]];
1888 ends.iter().any(|end| (*end - bottom).length() < 1.0e-5)
1889 && ends.iter().any(|end| (*end - top).length() < 1.0e-5)
1890 })
1891 .unwrap_or_else(|| panic!("no vertical edge over {corner:?}"));
1892
1893 compound
1894 .part_normal_constraints(part)
1895 .expect("polyhedron")
1896 .boundary_faces
1897 .iter()
1898 .find(|cone| (cone.face - face).length() < 1.0e-5)
1899 .expect("cone")
1900 .edge_pseudo_normals[edge]
1901 }
1902
1903 /// An edge a cut splits is still an edge of the union, so the two faces meeting along it open
1904 /// towards each other even though they belong to different parts.
1905 #[test]
1906 fn a_convex_edge_split_by_a_cut_opens_across_the_parts() {
1907 let compound = cut_ell();
1908
1909 // Each prism keeps five faces and loses the cut quad it shares.
1910 for part in 0..2 {
1911 assert_eq!(
1912 compound
1913 .part_normal_constraints(part)
1914 .unwrap()
1915 .boundary_faces
1916 .len(),
1917 5,
1918 "part {part}"
1919 );
1920 }
1921
1922 // The union turns 90 degrees along the vertical edge over the origin, between the front
1923 // face of one prism and the left face of the other.
1924 let halfway = Vector::new(-1.0, -1.0, 0.0).normalize();
1925 for (part, face) in [(0, -Vector::Y), (1, -Vector::X)] {
1926 let pseudo_normal = pseudo_normal_at_vertical_edge(&compound, part, face, [0.0, 0.0]);
1927 assert!(
1928 (pseudo_normal - halfway).length() < 1.0e-5,
1929 "part {part} stopped at {pseudo_normal:?}"
1930 );
1931 }
1932 }
1933
1934 /// A concave edge has no outward range for its faces to share, so each keeps its own normal --
1935 /// the same thing that happens along an edge whose other face was cut away.
1936 #[test]
1937 fn a_concave_edge_split_by_a_cut_stays_closed() {
1938 let compound = cut_ell();
1939
1940 // The reflex edge over `(1, 1)`, where the step rises out of the lower arm of the L.
1941 for (part, face) in [(0, Vector::Y), (1, Vector::X)] {
1942 let pseudo_normal = pseudo_normal_at_vertical_edge(&compound, part, face, [1.0, 1.0]);
1943 assert!(
1944 (pseudo_normal - face).length() < 1.0e-5,
1945 "part {part} opened to {pseudo_normal:?}"
1946 );
1947 }
1948 }
1949
1950 /// A row of cuboids on a shared grid, the way a voxel body reaches a compound, optionally
1951 /// rotated as a whole.
1952 fn voxel_row(n: u32, size: Real, origin: Real, angle: Real) -> Compound {
1953 let half = size / 2.0;
1954 let rotation = Rotation::from_rotation_z(angle);
1955 let parts = (0..n)
1956 .map(|i| {
1957 let center = Vector::new(origin + i as Real * size, 0.0, 0.0);
1958 (
1959 Pose::from_parts(rotation * center, rotation),
1960 SharedShape::new(Cuboid::new(Vector::new(half, half, half))),
1961 )
1962 })
1963 .collect();
1964
1965 let mut compound = Compound::new(parts);
1966 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
1967 compound
1968 }
1969
1970 /// How many boundary faces each part of a row kept. A welded row leaves the two ends with
1971 /// five and every box between them with four.
1972 fn boundary_face_counts(compound: &Compound, n: u32) -> Vec<usize> {
1973 (0..n)
1974 .map(|i| {
1975 compound
1976 .part_normal_constraints(i)
1977 .map_or(99, |cones| cones.boundary_faces.len())
1978 })
1979 .collect()
1980 }
1981
1982 /// A cuboid is a polyhedron the type system does not call one, so a voxel body used to get no
1983 /// cones at all; and its parts compute their own corners, which need not round alike.
1984 #[test]
1985 fn a_row_of_cuboids_shares_its_faces() {
1986 for &(size, origin, angle) in &[
1987 (1.0 as Real, 0.0 as Real, 0.0 as Real),
1988 (0.1, 0.0, 0.0),
1989 (0.7, 0.0, 0.0),
1990 (1.0, 0.05, 0.0),
1991 (0.1, 1000.0, 0.0),
1992 (1.0, 0.0, 0.1),
1993 (1.0, 1000.0, 0.7854),
1994 ] {
1995 assert_eq!(
1996 boundary_face_counts(&voxel_row(5, size, origin, angle), 5),
1997 vec![5, 4, 4, 4, 5],
1998 "size {size} origin {origin} angle {angle}"
1999 );
2000 }
2001 }
2002
2003 /// A zero tolerance is the exact comparison, which only recognizes a cut whose corners round
2004 /// to the very same bits on both sides.
2005 #[test]
2006 fn a_zero_tolerance_welds_only_bit_identical_corners() {
2007 let mut rotated = voxel_row(5, 1.0, 0.0, 0.1);
2008 let welded = boundary_face_counts(&rotated, 5);
2009 assert_eq!(welded, vec![5, 4, 4, 4, 5]);
2010
2011 // Dropping the tolerance can only lose cuts, never find new ones, and this rotation puts
2012 // at least one seam beyond an exact comparison.
2013 rotated.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, Some(0.0));
2014 let exact = boundary_face_counts(&rotated, 5);
2015 assert!(
2016 exact.iter().zip(welded.iter()).all(|(a, b)| a >= b),
2017 "exact found a cut the tolerance missed: {exact:?}"
2018 );
2019 assert!(
2020 exact.iter().sum::<usize>() > welded.iter().sum::<usize>(),
2021 "exact matched every seam: {exact:?}"
2022 );
2023
2024 // A grid whose corners land on exact coordinates never needed the tolerance.
2025 let mut exact = voxel_row(5, 1.0, 0.0, 0.0);
2026 exact.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, Some(0.0));
2027 assert_eq!(boundary_face_counts(&exact, 5), vec![5, 4, 4, 4, 5]);
2028 }
2029
2030 /// A body pressed into the wedge tip reports a normal along its own face, not a surface of
2031 /// the union; the cones pull it onto the one face that is.
2032 #[test]
2033 fn a_tip_normal_is_clamped_onto_the_ramp_face() {
2034 use crate::query::details::NormalConstraints;
2035
2036 let mut compound = ramp_foot();
2037 compound.set_flags(CompoundFlags::FIX_INTERNAL_EDGES, None);
2038 let wedge = compound.part_normal_constraints(1).expect("polyhedron");
2039
2040 let ramp = Vector::new(-0.6, 0.8, 0.0);
2041 let clamped = wedge
2042 .project_local_normal(Vector::new(-1.0, 0.0, 0.0))
2043 .expect("tip direction faces the outline");
2044 assert!((clamped - ramp).length() < 1.0e-4, "clamped to {clamped:?}");
2045
2046 // A direction the outline genuinely faces is left alone.
2047 let kept = wedge.project_local_normal(ramp).expect("faces the outline");
2048 assert!((kept - ramp).length() < 1.0e-4, "moved to {kept:?}");
2049 }
2050}