@@ -189,6 +189,34 @@ def build_dynamic_static_sphere_scene(
189189 return builder
190190
191191
192+ def build_box_on_plane_scene (
193+ box_z : float ,
194+ margin : float = 0.0 ,
195+ gap : float = 0.0 ,
196+ half_extent : float = 0.25 ,
197+ ) -> ModelBuilder :
198+ """Construct a single free box hovering above / resting on a static ground plane.
199+
200+ The box centre is placed at ``box_z`` so that its bottom face sits at
201+ ``box_z - half_extent`` above the ground surface (``z = 0``).
202+ """
203+ cfg = ModelBuilder .ShapeConfig (margin = margin , gap = gap )
204+ builder = ModelBuilder ()
205+ builder .begin_world ()
206+ body = builder .add_link (xform = wp .transform (p = wp .vec3 (0.0 , 0.0 , box_z ), q = wp .quat_identity ()))
207+ builder .add_shape_box (body , hx = half_extent , hy = half_extent , hz = half_extent , cfg = cfg )
208+ joint = builder .add_joint_free (
209+ parent = - 1 ,
210+ child = body ,
211+ parent_xform = wp .transform_identity (),
212+ child_xform = wp .transform_identity (),
213+ )
214+ builder .add_articulation ([joint ])
215+ builder .add_ground_plane (cfg = cfg )
216+ builder .end_world ()
217+ return builder
218+
219+
192220###
193221# Module configs
194222###
@@ -1473,6 +1501,120 @@ def test_09_multi_world_per_world_capacity_not_starved(self):
14731501 bid_AB = kamino .bid_AB .numpy ()[:2 ]
14741502 self .assertTrue (np .all (bid_AB [:, 1 ] >= 0 ))
14751503
1504+ def test_10_speculative_contacts_culled (self ):
1505+ """Speculative box-plane contacts are dropped by default.
1506+
1507+ Both configurations use a non-zero gap so that the collision detector
1508+ reports contacts while the box still hovers above the rest offset.
1509+ """
1510+ half_extent = 0.25
1511+
1512+ for margin , gap , box_z in (
1513+ (0.0 , 0.1 , half_extent + 0.05 ),
1514+ (0.02 , 0.1 , half_extent + 0.08 ),
1515+ ):
1516+ with self .subTest (margin = margin , gap = gap ):
1517+ scene = build_box_on_plane_scene (box_z , margin = margin , gap = gap , half_extent = half_extent )
1518+ model = scene .finalize (self .default_device )
1519+ state = model .state ()
1520+ collision_pipeline = newton .CollisionPipeline (model , rigid_contact_max = _NEWTON_CONTACT_CAPACITY )
1521+ contacts = collision_pipeline .contacts ()
1522+ collision_pipeline .collide (state , contacts )
1523+ nc = int (contacts .rigid_contact_count .numpy ()[0 ])
1524+ self .assertGreater (nc , 0 , "Detector must report the hovering box as a (speculative) contact" )
1525+
1526+ # Sanity: with culling disabled every converted contact is speculative.
1527+ kamino_off = ContactsKamino (capacity = contacts .rigid_contact_max , device = self .default_device )
1528+ convert_contacts_newton_to_kamino (model , state , contacts , kamino_off , cull_speculative_contacts = False )
1529+ n_off = int (kamino_off .model_active_contacts .numpy ()[0 ])
1530+ self .assertEqual (n_off , nc , "Disabling culling must keep every detected contact" )
1531+ w = kamino_off .gapfunc .numpy ()[:n_off , 3 ]
1532+ self .assertTrue (np .all (w > 0.0 ), f"Expected speculative contacts (w > 0), got { w } " )
1533+
1534+ # Default conversion culls all of them.
1535+ kamino_on = ContactsKamino (capacity = contacts .rigid_contact_max , device = self .default_device )
1536+ convert_contacts_newton_to_kamino (model , state , contacts , kamino_on )
1537+ self .assertEqual (
1538+ int (kamino_on .model_active_contacts .numpy ()[0 ]),
1539+ 0 ,
1540+ "Speculative contacts must be culled by default" ,
1541+ )
1542+
1543+ def test_11_genuine_contacts_kept (self ):
1544+ """Genuine box-plane contacts (gapfunc.w <= 0) survive culling.
1545+
1546+ Covers the full ``(gap, margin)`` matrix with the box placed at or below
1547+ the rest offset so no contact is speculative.
1548+ """
1549+ half_extent = 0.25
1550+
1551+ for margin , gap , box_z in (
1552+ (0.0 , 0.0 , half_extent - 0.01 ),
1553+ (0.0 , 0.1 , half_extent - 0.01 ),
1554+ (0.02 , 0.0 , half_extent + 0.02 ),
1555+ (0.02 , 0.1 , half_extent + 0.02 ),
1556+ ):
1557+ with self .subTest (margin = margin , gap = gap ):
1558+ scene = build_box_on_plane_scene (box_z , margin = margin , gap = gap , half_extent = half_extent )
1559+ model = scene .finalize (self .default_device )
1560+ state = model .state ()
1561+ collision_pipeline = newton .CollisionPipeline (model , rigid_contact_max = _NEWTON_CONTACT_CAPACITY )
1562+ contacts = collision_pipeline .contacts ()
1563+ collision_pipeline .collide (state , contacts )
1564+ nc = int (contacts .rigid_contact_count .numpy ()[0 ])
1565+ self .assertGreater (nc , 0 , "Detector must report the penetrating box as a contact" )
1566+
1567+ kamino = ContactsKamino (capacity = contacts .rigid_contact_max , device = self .default_device )
1568+ convert_contacts_newton_to_kamino (model , state , contacts , kamino )
1569+ n_kamino = int (kamino .model_active_contacts .numpy ()[0 ])
1570+ self .assertEqual (n_kamino , nc , "Genuine contacts must not be culled" )
1571+ w = kamino .gapfunc .numpy ()[:n_kamino , 3 ]
1572+ self .assertTrue (np .all (w <= 1e-6 ), f"Expected genuine contacts (w <= 0), got { w } " )
1573+
1574+ def test_12_culled_contacts_report_zero_force (self ):
1575+ """Culled contacts report zero force through the K->N existing path.
1576+
1577+ Regression: the existing-contacts write-back only touches surviving
1578+ (remapped) contacts, so culled slots must be zeroed rather than left
1579+ holding stale prior-frame force data.
1580+ """
1581+ half_extent = 0.25
1582+
1583+ # Box hovering within the detection gap -> every contact is speculative.
1584+ scene = build_box_on_plane_scene (half_extent + 0.05 , margin = 0.0 , gap = 0.1 , half_extent = half_extent )
1585+ scene .request_contact_attributes ("force" )
1586+ model = scene .finalize (self .default_device )
1587+ state = model .state ()
1588+ collision_pipeline = newton .CollisionPipeline (model , rigid_contact_max = _NEWTON_CONTACT_CAPACITY )
1589+ contacts = collision_pipeline .contacts ()
1590+ collision_pipeline .collide (state , contacts )
1591+ nc = int (contacts .rigid_contact_count .numpy ()[0 ])
1592+ self .assertGreater (nc , 0 )
1593+
1594+ # Seed stale prior-frame force data on every active contact slot.
1595+ force_np = contacts .force .numpy ()
1596+ force_np [:nc ] = 9.0
1597+ contacts .force .assign (force_np )
1598+
1599+ # N->K culls every (speculative) contact.
1600+ kamino = ContactsKamino (
1601+ capacity = contacts .rigid_contact_max ,
1602+ device = self .default_device ,
1603+ remappable = True ,
1604+ )
1605+ convert_contacts_newton_to_kamino (model , state , contacts , kamino , convert_forces = True )
1606+ self .assertEqual (int (kamino .model_active_contacts .numpy ()[0 ]), 0 , "All contacts should be culled" )
1607+
1608+ # K->N existing path must not leave stale force on the culled contacts.
1609+ convert_contacts_kamino_to_newton (model , state , kamino , contacts , clear_output = False , convert_forces = True )
1610+
1611+ force_after = contacts .force .numpy ()[:nc ]
1612+ np .testing .assert_array_equal (
1613+ force_after ,
1614+ np .zeros_like (force_after ),
1615+ err_msg = "Culled contacts must not retain stale force data" ,
1616+ )
1617+
14761618
14771619###
14781620# Test execution
0 commit comments