@@ -55,6 +55,52 @@ fn build_ball(world: &mut PhysicsWorld2D) -> RigidBody2D {
5555 return ball;
5656}
5757
58+ /// Creates a static body with two overlapping circle colliders.
59+ ///
60+ /// # Arguments
61+ /// - `world`: The world that will own the compound body.
62+ ///
63+ /// # Returns
64+ /// Returns the created compound rigid body handle.
65+ fn build_compound_circle_body ( world : & mut PhysicsWorld2D ) -> RigidBody2D {
66+ let body = RigidBody2DBuilder :: new ( RigidBodyType :: Static )
67+ . build ( world)
68+ . unwrap ( ) ;
69+
70+ Collider2DBuilder :: circle ( 0.5 )
71+ . with_offset ( -0.25 , 0.0 )
72+ . build ( world, body)
73+ . unwrap ( ) ;
74+ Collider2DBuilder :: circle ( 0.5 )
75+ . with_offset ( 0.25 , 0.0 )
76+ . build ( world, body)
77+ . unwrap ( ) ;
78+
79+ return body;
80+ }
81+
82+ /// Creates a dynamic ball already positioned in overlap.
83+ ///
84+ /// # Arguments
85+ /// - `world`: The world that will own the body.
86+ /// - `position`: The initial body position in meters.
87+ ///
88+ /// # Returns
89+ /// Returns the created rigid body handle.
90+ fn build_overlapping_ball (
91+ world : & mut PhysicsWorld2D ,
92+ position : [ f32 ; 2 ] ,
93+ ) -> RigidBody2D {
94+ let ball = RigidBody2DBuilder :: new ( RigidBodyType :: Dynamic )
95+ . with_position ( position[ 0 ] , position[ 1 ] )
96+ . build ( world)
97+ . unwrap ( ) ;
98+
99+ Collider2DBuilder :: circle ( 0.5 ) . build ( world, ball) . unwrap ( ) ;
100+
101+ return ball;
102+ }
103+
58104/// Steps until at least one collision event is produced.
59105///
60106/// # Arguments
@@ -79,6 +125,33 @@ fn step_until_collision_events(
79125 panic ! ( "expected collision events within {max_steps} steps" ) ;
80126}
81127
128+ /// Steps until a collision event of the requested kind is produced.
129+ ///
130+ /// # Arguments
131+ /// - `world`: The world to step.
132+ /// - `kind`: The event kind to wait for.
133+ /// - `max_steps`: The maximum number of steps to attempt.
134+ ///
135+ /// # Returns
136+ /// Returns all drained events from the first step that produced the requested
137+ /// event kind.
138+ fn step_until_collision_event_kind (
139+ world : & mut PhysicsWorld2D ,
140+ kind : CollisionEventKind ,
141+ max_steps : u32 ,
142+ ) -> Vec < CollisionEvent > {
143+ for _ in 0 ..max_steps {
144+ world. step ( ) ;
145+
146+ let events: Vec < CollisionEvent > = world. collision_events ( ) . collect ( ) ;
147+ if events. iter ( ) . any ( |event| event. kind == kind) {
148+ return events;
149+ }
150+ }
151+
152+ panic ! ( "expected {kind:?} event within {max_steps} steps" ) ;
153+ }
154+
82155/// Ensures first contact emits a single `Started` event.
83156#[ test]
84157fn physics_2d_collision_events_first_contact_emits_started ( ) {
@@ -169,3 +242,142 @@ fn physics_2d_collision_events_started_includes_contact_data() {
169242
170243 return ;
171244}
245+
246+ /// Ensures separation emits one `Ended` event.
247+ #[ test]
248+ fn physics_2d_collision_events_separation_emits_ended ( ) {
249+ let mut world = PhysicsWorld2DBuilder :: new ( )
250+ . with_gravity ( 0.0 , 0.0 )
251+ . build ( )
252+ . unwrap ( ) ;
253+
254+ let ground = build_ground ( & mut world) ;
255+ let ball = build_overlapping_ball ( & mut world, [ 0.0 , 0.0 ] ) ;
256+
257+ let started_events =
258+ step_until_collision_event_kind ( & mut world, CollisionEventKind :: Started , 1 ) ;
259+ assert_eq ! (
260+ started_events
261+ . iter( )
262+ . filter( |event| event. kind == CollisionEventKind :: Started )
263+ . count( ) ,
264+ 1 ,
265+ ) ;
266+
267+ ball. set_position ( & mut world, 0.0 , 4.0 ) . unwrap ( ) ;
268+ let ended_events =
269+ step_until_collision_event_kind ( & mut world, CollisionEventKind :: Ended , 1 ) ;
270+ let ended_event = ended_events
271+ . into_iter ( )
272+ . find ( |event| event. kind == CollisionEventKind :: Ended )
273+ . unwrap ( ) ;
274+
275+ assert_eq ! ( ended_event. body_a, ground) ;
276+ assert_eq ! ( ended_event. body_b, ball) ;
277+
278+ return ;
279+ }
280+
281+ /// Ensures `Ended` omits contact payload fields.
282+ #[ test]
283+ fn physics_2d_collision_events_ended_has_no_contact_payload ( ) {
284+ let mut world = PhysicsWorld2DBuilder :: new ( )
285+ . with_gravity ( 0.0 , 0.0 )
286+ . build ( )
287+ . unwrap ( ) ;
288+
289+ build_ground ( & mut world) ;
290+ let ball = build_overlapping_ball ( & mut world, [ 0.0 , 0.0 ] ) ;
291+
292+ step_until_collision_event_kind ( & mut world, CollisionEventKind :: Started , 1 ) ;
293+ ball. set_position ( & mut world, 0.0 , 4.0 ) . unwrap ( ) ;
294+
295+ let ended_event =
296+ step_until_collision_event_kind ( & mut world, CollisionEventKind :: Ended , 1 )
297+ . into_iter ( )
298+ . find ( |event| event. kind == CollisionEventKind :: Ended )
299+ . unwrap ( ) ;
300+
301+ assert_eq ! ( ended_event. contact_point, None ) ;
302+ assert_eq ! ( ended_event. normal, None ) ;
303+ assert_eq ! ( ended_event. penetration, None ) ;
304+
305+ return ;
306+ }
307+
308+ /// Ensures compound colliders still emit one event per body pair.
309+ #[ test]
310+ fn physics_2d_collision_events_compound_colliders_emit_one_body_pair_event ( ) {
311+ let mut world = PhysicsWorld2DBuilder :: new ( )
312+ . with_gravity ( 0.0 , 0.0 )
313+ . build ( )
314+ . unwrap ( ) ;
315+
316+ let compound_body = build_compound_circle_body ( & mut world) ;
317+ let ball = build_overlapping_ball ( & mut world, [ 0.0 , 0.0 ] ) ;
318+
319+ let started_events =
320+ step_until_collision_event_kind ( & mut world, CollisionEventKind :: Started , 1 ) ;
321+
322+ assert_eq ! (
323+ started_events
324+ . iter( )
325+ . filter( |event| event. kind == CollisionEventKind :: Started )
326+ . count( ) ,
327+ 1 ,
328+ ) ;
329+ assert_eq ! (
330+ started_events
331+ . iter( )
332+ . find( |event| event. kind == CollisionEventKind :: Started )
333+ . unwrap( )
334+ . body_a,
335+ compound_body,
336+ ) ;
337+ assert_eq ! (
338+ started_events
339+ . iter( )
340+ . find( |event| event. kind == CollisionEventKind :: Started )
341+ . unwrap( )
342+ . body_b,
343+ ball,
344+ ) ;
345+
346+ ball. set_position ( & mut world, 3.0 , 0.0 ) . unwrap ( ) ;
347+ let ended_events =
348+ step_until_collision_event_kind ( & mut world, CollisionEventKind :: Ended , 1 ) ;
349+
350+ assert_eq ! (
351+ ended_events
352+ . iter( )
353+ . filter( |event| event. kind == CollisionEventKind :: Ended )
354+ . count( ) ,
355+ 1 ,
356+ ) ;
357+
358+ return ;
359+ }
360+
361+ /// Ensures queued events survive multiple steps until drained.
362+ #[ test]
363+ fn physics_2d_collision_events_preserve_queue_across_multiple_steps ( ) {
364+ let mut world = PhysicsWorld2DBuilder :: new ( )
365+ . with_gravity ( 0.0 , 0.0 )
366+ . build ( )
367+ . unwrap ( ) ;
368+
369+ build_ground ( & mut world) ;
370+ let ball = build_overlapping_ball ( & mut world, [ 0.0 , 0.0 ] ) ;
371+
372+ world. step ( ) ;
373+ ball. set_position ( & mut world, 0.0 , 4.0 ) . unwrap ( ) ;
374+ world. step ( ) ;
375+
376+ let events: Vec < CollisionEvent > = world. collision_events ( ) . collect ( ) ;
377+
378+ assert_eq ! ( events. len( ) , 2 ) ;
379+ assert_eq ! ( events[ 0 ] . kind, CollisionEventKind :: Started ) ;
380+ assert_eq ! ( events[ 1 ] . kind, CollisionEventKind :: Ended ) ;
381+
382+ return ;
383+ }
0 commit comments