Skip to main content

parry3d/query/sweep_toi/
composite.rs

1//! Sweep time-of-impact against composite shapes (meshes, polylines, heightfields,
2//! compounds): the composite is assumed stationary, its acceleration structure is queried
3//! with the swept bounds of the moving shape, and each candidate element runs the convex
4//! TOI with a fallback sphere when the element reports an initial overlap.
5
6use super::sweep::Sweep;
7use super::sweep_toi::{sweep_time_of_impact, SweepToiOutput, SweepToiStatus};
8use super::toi_proxy::ToiProxy;
9use crate::bounding_volume::BoundingVolume;
10use crate::math::{Pose, Real, Vector};
11use crate::shape::{Shape, TypedShape};
12
13#[cfg(feature = "dim2")]
14use crate::shape::{PolylineFlags, Segment};
15#[cfg(feature = "dim3")]
16use crate::shape::{TriMeshFlags, Triangle};
17
18/// Fraction of the fast shape’s minimum extent used for the initial-overlap fallback sphere.
19pub const CORE_FRACTION: Real = 0.25;
20
21/// Parameters describing the fast (moving) shape for a composite TOI query.
22#[derive(Copy, Clone)]
23pub struct SweepCompositeFastShape<'a, 'b> {
24    /// Point-cloud proxy of the moving shape.
25    pub proxy: &'a ToiProxy<'b>,
26    /// Sweep of the moving shape.
27    pub sweep: &'a Sweep,
28    /// Centroid of the moving shape in its local frame.
29    pub local_centroid: Vector,
30    /// Smallest extent (inner radius) of the moving shape, used for fallback spheres and
31    /// one-sided early-outs.
32    pub min_extent: Real,
33}
34
35struct CompositeToiContext<'a, 'b> {
36    fast: SweepCompositeFastShape<'a, 'b>,
37    // Centroid of the moving shape at the sweep endpoints, in the composite’s local frame.
38    local_centroid1: Vector,
39    local_centroid2: Vector,
40    fallback_radius: Real,
41    one_sided: bool,
42    #[cfg_attr(feature = "dim2", allow(dead_code))]
43    target_is_sensor: bool,
44    linear_slop: Real,
45    max_fraction: Real,
46    best: Option<SweepToiOutput>,
47}
48
49impl CompositeToiContext<'_, '_> {
50    /// Runs the convex TOI of the moving shape against one composite element and keeps the
51    /// earliest hit, with a fallback-sphere retry on initial overlap.
52    fn toi_against_element(&mut self, element_proxy: &ToiProxy, element_sweep: &Sweep) {
53        let output = sweep_time_of_impact(
54            element_proxy,
55            element_sweep,
56            self.fast.proxy,
57            self.fast.sweep,
58            self.max_fraction,
59            self.linear_slop,
60        );
61
62        if 0.0 < output.fraction && output.fraction < self.max_fraction {
63            self.max_fraction = output.fraction;
64            self.best = Some(output);
65        } else if output.fraction == 0.0 {
66            // Fallback to the TOI of a small ball around the fast shape centroid.
67            #[cfg(feature = "dim2")]
68            let radius = self.fallback_radius;
69            #[cfg(feature = "dim3")]
70            let radius = self.fallback_radius + self.linear_slop;
71
72            let fallback_proxy = ToiProxy::point(self.fast.local_centroid, radius);
73            let output = sweep_time_of_impact(
74                element_proxy,
75                element_sweep,
76                &fallback_proxy,
77                self.fast.sweep,
78                self.max_fraction,
79                self.linear_slop,
80            );
81
82            if 0.0 < output.fraction && output.fraction < self.max_fraction {
83                self.max_fraction = output.fraction;
84                self.best = Some(output);
85            }
86        }
87    }
88
89    /// One-sided early-out for a 2D chain segment.
90    /// Returns `true` when the element can be skipped.
91    #[cfg(feature = "dim2")]
92    fn one_sided_early_out(&self, segment: &Segment) -> bool {
93        if !self.one_sided {
94            return false;
95        }
96
97        let e = segment.b - segment.a;
98        let length = e.length();
99        if length <= self.linear_slop {
100            return false;
101        }
102        let e = e / length;
103
104        let separation1 = (self.local_centroid1 - segment.a).perp_dot(e);
105        let separation2 = (self.local_centroid2 - segment.a).perp_dot(e);
106        let core_distance = CORE_FRACTION * self.fast.min_extent;
107
108        separation1 < 0.0
109            || (separation1 - separation2 < core_distance && separation2 > core_distance)
110    }
111
112    /// One-sided early-out for a 3D triangle.
113    /// Returns `true` when the element can be skipped.
114    #[cfg(feature = "dim3")]
115    fn one_sided_early_out(&self, triangle: &Triangle) -> bool {
116        if !self.one_sided {
117            return false;
118        }
119
120        let n = (triangle.b - triangle.a)
121            .cross(triangle.c - triangle.a)
122            .normalize_or_zero();
123        let offset1 = n.dot(self.local_centroid1 - triangle.a);
124        let offset2 = n.dot(self.local_centroid2 - triangle.a);
125
126        if offset1 < 0.0 {
127            // Started behind.
128            return true;
129        }
130
131        if !self.target_is_sensor
132            && offset1 - offset2 < self.fallback_radius
133            && offset2 > self.fallback_radius
134        {
135            // Finished in front.
136            return true;
137        }
138
139        false
140    }
141}
142
143/// Computes the time of impact between a stationary composite shape and a moving convex
144/// shape.
145///
146/// Returns `None` if `composite` is not a supported composite shape (triangle mesh,
147/// polyline, heightfield, or compound). When supported but nothing is hit, the returned
148/// output has status [`SweepToiStatus::Separated`] and `fraction == max_fraction`.
149///
150/// `one_sided` enables the “started behind / finished in front” early-outs; it
151/// should only be set for composites whose elements have meaningful outward normals
152/// (oriented polylines, oriented meshes, heightfields).
153#[allow(clippy::too_many_arguments)]
154pub fn sweep_time_of_impact_composite(
155    composite: &dyn Shape,
156    composite_pose: &Pose,
157    fast: SweepCompositeFastShape,
158    one_sided: bool,
159    target_is_sensor: bool,
160    max_fraction: Real,
161    linear_slop: Real,
162) -> Option<SweepToiOutput> {
163    let typed = composite.as_typed_shape();
164
165    // Fallback sphere radius (2D: core circle; 3D: mesh/compound fallback spheres).
166    #[cfg(feature = "dim2")]
167    let fallback_radius = CORE_FRACTION * fast.min_extent;
168    #[cfg(feature = "dim3")]
169    let fallback_radius = match typed {
170        TypedShape::Compound(_) => (0.75 * fast.min_extent).max(4.0 * linear_slop),
171        _ => (0.5 * fast.min_extent).max(linear_slop),
172    };
173
174    // Swept bounds of the fast shape, in the composite’s local frame.
175    let start_aabb = fast.proxy.compute_aabb(&fast.sweep.transform_at(0.0));
176    let end_aabb = fast
177        .proxy
178        .compute_aabb(&fast.sweep.transform_at(max_fraction));
179    let local_aabb = start_aabb
180        .merged(&end_aabb)
181        .transform_by(&composite_pose.inverse());
182
183    // Centroid of the fast shape at the sweep endpoints, in the composite’s local frame.
184    let centroid_world1 = fast
185        .sweep
186        .transform_at(0.0)
187        .transform_point(fast.local_centroid);
188    let centroid_world2 = fast
189        .sweep
190        .final_transform()
191        .transform_point(fast.local_centroid);
192
193    let mut context = CompositeToiContext {
194        fast,
195        local_centroid1: composite_pose.inverse_transform_point(centroid_world1),
196        local_centroid2: composite_pose.inverse_transform_point(centroid_world2),
197        fallback_radius,
198        one_sided,
199        target_is_sensor,
200        linear_slop,
201        max_fraction,
202        best: None,
203    };
204
205    // The composite is stationary: every element sweep is degenerate at the composite pose
206    // (composed with the child pose for compounds).
207    let composite_sweep = Sweep::constant(composite_pose, Vector::ZERO);
208
209    match typed {
210        #[cfg(feature = "dim3")]
211        TypedShape::TriMesh(mesh) => {
212            let one_sided_mesh = mesh.flags().contains(TriMeshFlags::ORIENTED);
213            context.one_sided = one_sided || one_sided_mesh;
214            for tri_id in mesh.bvh().intersect_aabb(&local_aabb) {
215                let triangle = mesh.triangle(tri_id);
216                if context.one_sided_early_out(&triangle) {
217                    continue;
218                }
219                let proxy = ToiProxy::from_array([triangle.a, triangle.b, triangle.c], 0.0);
220                context.toi_against_element(&proxy, &composite_sweep);
221            }
222        }
223        #[cfg(feature = "dim2")]
224        TypedShape::TriMesh(mesh) => {
225            for tri_id in mesh.bvh().intersect_aabb(&local_aabb) {
226                let triangle = mesh.triangle(tri_id);
227                let proxy = ToiProxy::from_array([triangle.a, triangle.b, triangle.c], 0.0);
228                context.toi_against_element(&proxy, &composite_sweep);
229            }
230        }
231        TypedShape::Polyline(polyline) => {
232            #[cfg(feature = "dim2")]
233            {
234                let oriented = polyline.flags().contains(PolylineFlags::ORIENTED);
235                context.one_sided = one_sided || oriented;
236            }
237            for seg_id in polyline.bvh().intersect_aabb(&local_aabb) {
238                let segment = polyline.segment(seg_id);
239                #[cfg(feature = "dim2")]
240                if context.one_sided_early_out(&segment) {
241                    continue;
242                }
243                let proxy = ToiProxy::from_array([segment.a, segment.b], 0.0);
244                context.toi_against_element(&proxy, &composite_sweep);
245            }
246        }
247        TypedShape::HeightField(heightfield) => {
248            #[cfg(feature = "dim2")]
249            heightfield.map_elements_in_local_aabb(&local_aabb, &mut |_, segment| {
250                if !context.one_sided_early_out(segment) {
251                    let proxy = ToiProxy::from_array([segment.a, segment.b], 0.0);
252                    context.toi_against_element(&proxy, &composite_sweep);
253                }
254            });
255            #[cfg(feature = "dim3")]
256            heightfield.map_elements_in_local_aabb(&local_aabb, &mut |_, triangle| {
257                if !context.one_sided_early_out(triangle) {
258                    let proxy = ToiProxy::from_array([triangle.a, triangle.b, triangle.c], 0.0);
259                    context.toi_against_element(&proxy, &composite_sweep);
260                }
261            });
262        }
263        TypedShape::Compound(compound) => {
264            for child_id in compound.bvh().intersect_aabb(&local_aabb) {
265                let (child_pose, child_shape) = &compound.shapes()[child_id as usize];
266                let child_world_pose = *composite_pose * *child_pose;
267                if let Some(child_proxy) = ToiProxy::from_shape(child_shape.as_ref()) {
268                    let child_sweep = Sweep::constant(&child_world_pose, Vector::ZERO);
269                    context.toi_against_element(&child_proxy, &child_sweep);
270                } else if let Some(hit) = sweep_time_of_impact_composite(
271                    child_shape.as_ref(),
272                    &child_world_pose,
273                    context.fast,
274                    context.one_sided,
275                    target_is_sensor,
276                    context.max_fraction,
277                    linear_slop,
278                ) {
279                    if 0.0 < hit.fraction && hit.fraction < context.max_fraction {
280                        context.max_fraction = hit.fraction;
281                        context.best = Some(hit);
282                    }
283                }
284                // Children that are neither point-cloud shapes nor composites are skipped.
285            }
286        }
287        _ => return None,
288    }
289
290    Some(context.best.unwrap_or(SweepToiOutput {
291        status: SweepToiStatus::Separated,
292        fraction: max_fraction,
293        point: Vector::ZERO,
294        normal: Vector::ZERO,
295    }))
296}