1212import numpy as np
1313import warp as wp
1414
15+ from newton import ModelBuilder
1516from newton ._src .solvers .kamino ._src .core .builder import ModelBuilderKamino
1617from newton ._src .solvers .kamino ._src .core .data import DataKamino
17- from newton ._src .solvers .kamino ._src .core .math import I_3
1818from newton ._src .solvers .kamino ._src .core .model import ModelKamino
19- from newton ._src .solvers .kamino ._src .core .shapes import SphereShape
2019from newton ._src .solvers .kamino ._src .geometry .contacts import ContactsKamino
2120from newton ._src .solvers .kamino ._src .geometry .unified import CollisionPipelineUnifiedKamino
2221from newton ._src .solvers .kamino ._src .models .builders import basics , testing
@@ -630,7 +629,11 @@ def test_03_gap_rejects_distant_contact(self):
630629
631630
632631class TestUnifiedPipelineNxnBroadphase (unittest .TestCase ):
633- """Tests verifying NXN broadphase correctness with collision radii and filter pairs."""
632+ """Tests verifying NXN broadphase correctness with collision radii and filter pairs.
633+
634+ These tests are Kamino-specific and ensure that the combination of model
635+ creation/conversion and pipeline setup reproduce the desired behavior.
636+ """
634637
635638 def setUp (self ):
636639 if not test_context .setup_done :
@@ -640,29 +643,66 @@ def setUp(self):
640643 def tearDown (self ):
641644 self .default_device = None
642645
643- def _make_two_sphere_builder (self , group_a = 1 , collides_a = 1 , group_b = 1 , collides_b = 1 , same_body = False ):
646+ def _make_two_sphere_builder (
647+ self ,
648+ group_a = 1 ,
649+ group_b = 1 ,
650+ same_body = False ,
651+ collidable_a = True ,
652+ collidable_b = True ,
653+ ):
644654 """Helper: build a single-world scene with two spheres near each other."""
645- builder = ModelBuilderKamino ()
646- builder .add_world ()
647- bid_a = builder .add_rigid_body (
648- m_i = 1.0 ,
649- i_I_i = I_3 ,
650- q_i_0 = wp .transformf (0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 ),
651- u_i_0 = wp .spatial_vectorf (0.0 ),
655+ builder = ModelBuilder ()
656+ builder .begin_world ()
657+ bid_a = builder .add_body (
658+ mass = 1.0 ,
659+ inertia = wp .mat33 (np .eye (3 , dtype = np .float32 )),
660+ xform = wp .transformf (0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 0.0 , 1.0 ),
652661 )
653662 if same_body :
654663 bid_b = bid_a
655664 else :
656- bid_b = builder .add_rigid_body (
657- m_i = 1.0 ,
658- i_I_i = I_3 ,
659- q_i_0 = wp .transformf (0.0 , 0.0 , 1.0 , 0.0 , 0.0 , 0.0 , 1.0 ),
660- u_i_0 = wp .spatial_vectorf (0.0 ),
665+ bid_b = builder .add_body (
666+ mass = 1.0 ,
667+ inertia = wp .mat33 (np .eye (3 , dtype = np .float32 )),
668+ xform = wp .transformf (0.0 , 0.0 , 1.0 , 0.0 , 0.0 , 0.0 , 1.0 ),
661669 )
662- builder .add_geometry (body = bid_a , shape = SphereShape (radius = 0.5 ), group = group_a , collides = collides_a )
663- builder .add_geometry (body = bid_b , shape = SphereShape (radius = 0.5 ), group = group_b , collides = collides_b )
670+ builder .add_shape_sphere (
671+ body = bid_a ,
672+ radius = 0.5 ,
673+ cfg = ModelBuilder .ShapeConfig (collision_group = group_a , has_shape_collision = collidable_a ),
674+ )
675+ builder .add_shape_sphere (
676+ body = bid_b ,
677+ radius = 0.5 ,
678+ cfg = ModelBuilder .ShapeConfig (collision_group = group_b , has_shape_collision = collidable_b ),
679+ )
680+ builder .end_world ()
664681 return builder
665682
683+ def _run_two_sphere_pipeline (self , builder : ModelBuilder ) -> int :
684+ """Helper: convert a Newton builder to `ModelKamino` and run the NXN broadphase.
685+
686+ Returns the number of active contacts produced.
687+ """
688+ model = ModelKamino .from_newton (builder .finalize (self .default_device ))
689+ data = model .data ()
690+
691+ pipeline = CollisionPipelineUnifiedKamino (
692+ model = model ,
693+ broadphase = "nxn" ,
694+ default_gap = 1.0 ,
695+ )
696+
697+ n_geoms = builder .shape_count
698+ capacity = 12 * ((n_geoms * (n_geoms - 1 )) // 2 )
699+ contacts = ContactsKamino (capacity = max (capacity , 12 ), device = self .default_device )
700+ contacts .clear ()
701+
702+ pipeline .collide (data , contacts )
703+
704+ return int (contacts .model_active_contacts .numpy ()[0 ])
705+
666706 def test_00_nxn_sphere_on_plane_generates_contacts (self ):
667707 """Sphere resting on a plane via NXN broadphase must produce contacts.
668708
@@ -710,32 +750,41 @@ def test_01_nxn_box_on_plane_generates_contacts(self):
710750 device = self .default_device ,
711751 )
712752
713- def test_02_nxn_excludes_non_collidable_pairs (self ):
714- """NXN broadphase must exclude pairs whose group/collides bitmasks do not overlap.
715-
716- Creates two spheres in the same world but with non-overlapping
717- collision groups so that they should never collide.
718- """
719- builder = self ._make_two_sphere_builder (group_a = 0b01 , collides_a = 0b01 , group_b = 0b10 , collides_b = 0b10 )
720-
721- model = builder .finalize (self .default_device )
722- data = model .data ()
723-
724- pipeline = CollisionPipelineUnifiedKamino (
725- model = model ,
726- broadphase = "nxn" ,
727- default_gap = 1.0 ,
728- )
753+ def test_02_nxn_newton_collision_group_semantics (self ):
754+ """NXN broadphase must follow Newton's collision-group semantics.
729755
730- n_geoms = builder .num_geoms
731- capacity = 12 * ((n_geoms * (n_geoms - 1 )) // 2 )
732- contacts = ContactsKamino (capacity = max (capacity , 12 ), device = self .default_device )
733- contacts .clear ()
756+ Mirrors `newton._src.geometry.broad_phase_common.test_group_pair`:
757+ - group 0 never collides with anything, including another group 0.
758+ - A positive group collides only with the same positive group, or
759+ with any negative group.
760+ - A negative group collides with everything except the same
761+ negative group.
734762
735- pipeline .collide (data , contacts )
763+ Creates two touching spheres on different bodies in the same world
764+ and checks every representative combination of group signs/values.
765+ """
766+ cases = {
767+ "both_positive_equal" : (1 , 1 , True ),
768+ "both_positive_different" : (1 , 2 , False ),
769+ "positive_vs_zero" : (1 , 0 , False ),
770+ "both_zero" : (0 , 0 , False ),
771+ "positive_vs_negative_different" : (1 , - 1 , True ),
772+ "both_negative_equal" : (- 2 , - 2 , False ),
773+ "both_negative_different" : (- 1 , - 2 , True ),
774+ "negative_vs_zero" : (- 1 , 0 , False ),
775+ }
736776
737- active = contacts .model_active_contacts .numpy ()[0 ]
738- self .assertEqual (active , 0 , "Non-collidable groups must produce zero contacts via NXN" )
777+ for case , (group_a , group_b , should_collide ) in cases .items ():
778+ with self .subTest (case = case , group_a = group_a , group_b = group_b ):
779+ builder = self ._make_two_sphere_builder (group_a = group_a , group_b = group_b )
780+ active = self ._run_two_sphere_pipeline (builder )
781+ expected_active = 1 if should_collide else 0
782+ self .assertEqual (
783+ active ,
784+ expected_active ,
785+ f"groups ({ group_a } , { group_b } ) via nxn broadphase: "
786+ f"expected { 'a contact' if should_collide else 'no contacts' } " ,
787+ )
739788
740789 def test_03_nxn_same_body_excluded (self ):
741790 """NXN broadphase must exclude same-body shape pairs.
@@ -744,25 +793,27 @@ def test_03_nxn_same_body_excluded(self):
744793 that no self-collision contacts are produced.
745794 """
746795 builder = self ._make_two_sphere_builder (same_body = True )
796+ active = self ._run_two_sphere_pipeline (builder )
797+ self .assertEqual (active , 0 , "Same-body shapes must not collide via NXN broadphase" )
747798
748- model = builder .finalize (self .default_device )
749- data = model .data ()
750-
751- pipeline = CollisionPipelineUnifiedKamino (
752- model = model ,
753- broadphase = "nxn" ,
754- default_gap = 1.0 ,
755- )
756-
757- n_geoms = builder .num_geoms
758- capacity = 12 * ((n_geoms * (n_geoms - 1 )) // 2 )
759- contacts = ContactsKamino (capacity = max (capacity , 12 ), device = self .default_device )
760- contacts .clear ()
799+ def test_04_nxn_excludes_non_collidable_shapes (self ):
800+ """NXN broadphase must exclude shapes without the `COLLIDE_SHAPES` flag.
761801
762- pipeline .collide (data , contacts )
802+ A shape built with `ShapeConfig(has_shape_collision=False)` (e.g. a
803+ sensor or visual-only shape) must never generate a contact, even
804+ though its collision group would otherwise allow it to collide.
805+ """
806+ cases = {
807+ "shape_a_non_collidable" : (False , True ),
808+ "shape_b_non_collidable" : (True , False ),
809+ "both_non_collidable" : (False , False ),
810+ }
763811
764- active = contacts .model_active_contacts .numpy ()[0 ]
765- self .assertEqual (active , 0 , "Same-body shapes must not collide via NXN broadphase" )
812+ for case , (collidable_a , collidable_b ) in cases .items ():
813+ with self .subTest (case = case ):
814+ builder = self ._make_two_sphere_builder (collidable_a = collidable_a , collidable_b = collidable_b )
815+ active = self ._run_two_sphere_pipeline (builder )
816+ self .assertEqual (active , 0 , "Non-collidable shapes must not collide via NXN broadphase" )
766817
767818
768819###
0 commit comments