@@ -4658,6 +4658,201 @@ def test_isaaclab_mass_randomization_loop(self):
46584658 )
46594659
46604660
4661+ class TestMuJoCoSolverContactKf(unittest.TestCase):
4662+ """Verify shape_material_kf maps to elliptic-contact solreffriction."""
4663+
4664+ def _make_sphere_scene(self, kf_sphere, kf_plane, impratio=None, cone="elliptic"):
4665+ builder = newton.ModelBuilder()
4666+ builder.default_shape_cfg.ke = 1.0e4
4667+ builder.default_shape_cfg.kd = 100.0
4668+ builder.default_shape_cfg.kf = kf_plane
4669+ builder.add_ground_plane()
4670+ # start slightly penetrating so the first collide() yields an active contact
4671+ body = builder.add_body(xform=wp.transform(wp.vec3(0.0, 0.0, 0.45), wp.quat_identity()))
4672+ cfg = newton.ModelBuilder.ShapeConfig(density=1000.0, ke=1.0e4, kd=100.0, kf=kf_sphere)
4673+ builder.add_shape_sphere(body=body, radius=0.5, cfg=cfg)
4674+ model = builder.finalize()
4675+ try:
4676+ solver = SolverMuJoCo(model, use_mujoco_contacts=False, cone=cone, nconmax=32, njmax=128, impratio=impratio)
4677+ except ImportError as e:
4678+ self.skipTest(f"MuJoCo or deps not installed. Skipping test: {e}")
4679+ return model, solver
4680+
4681+ def _step_and_read_solreffriction(self, model, solver):
4682+ state_0, state_1 = model.state(), model.state()
4683+ control = model.control()
4684+ collision_pipeline = newton.CollisionPipeline(model)
4685+ contacts = collision_pipeline.contacts()
4686+ collision_pipeline.collide(state_0, contacts)
4687+ solver.step(state_0, state_1, control, contacts, 1.0 / 240.0)
4688+ nacon = int(solver.mjw_data.nacon.numpy()[0])
4689+ self.assertGreater(nacon, 0)
4690+ return nacon, (collision_pipeline, contacts), (state_0, state_1, control)
4691+
4692+ def _expected_solreffriction(self, solver, nacon, kf_pair, impratio=1.0):
4693+ solimp = solver.mjw_data.contact.solimp.numpy()[:nacon]
4694+ geom = solver.mjw_data.contact.geom.numpy()[:nacon]
4695+ geom_bodyid = solver.mjw_model.geom_bodyid.numpy()
4696+ body_invweight0 = solver.mjw_model.body_invweight0.numpy()[0]
4697+ ir = 1.0 / math.sqrt(impratio)
4698+ expected = []
4699+ for i in range(nacon):
4700+ invw = body_invweight0[geom_bodyid[geom[i][0]]][0] + body_invweight0[geom_bodyid[geom[i][1]]][0]
4701+ dmax = solimp[i][1]
4702+ # beta = kf*(1/D + A) with A ~= invw; timeconst = 2/(dmax*beta)
4703+ expected.append(2.0 / (kf_pair * invw * ((1.0 - dmax) * ir * ir + dmax)))
4704+ return expected
4705+
4706+ def test_kf_sets_contact_solreffriction(self):
4707+ """Verify positive kf sets the mixed contact solreffriction."""
4708+ model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0)
4709+ nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4710+ solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4711+ # equal solmix/priority -> mix = 0.5
4712+ expected = self._expected_solreffriction(solver, nacon, kf_pair=500.0)
4713+ for i in range(nacon):
4714+ self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4715+ self.assertEqual(float(solreffriction[i][1]), 1.0)
4716+
4717+ def test_kf_zero_mixes_with_positive_value(self):
4718+ """Verify zero kf participates in the usual contact-material mixing."""
4719+ model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=0.0)
4720+ nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4721+ solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4722+ expected = self._expected_solreffriction(solver, nacon, kf_pair=400.0)
4723+ for i in range(nacon):
4724+ self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4725+ self.assertEqual(float(solreffriction[i][1]), 1.0)
4726+
4727+ def test_kf_zero_disables_friction(self):
4728+ """Verify a resolved zero kf makes the contact frictionless."""
4729+ model, solver = self._make_sphere_scene(kf_sphere=0.0, kf_plane=0.0)
4730+ nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4731+ solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4732+ condim = solver.mjw_data.contact.dim.numpy()[:nacon]
4733+ for i in range(nacon):
4734+ self.assertEqual(int(condim[i]), 1)
4735+ self.assertEqual(float(solreffriction[i][0]), 0.0)
4736+ self.assertEqual(float(solreffriction[i][1]), 0.0)
4737+
4738+ def test_kf_zero_does_not_change_pyramidal_contacts(self):
4739+ """Verify zero kf leaves pyramidal friction contacts unchanged."""
4740+ model, solver = self._make_sphere_scene(kf_sphere=0.0, kf_plane=0.0, cone="pyramidal")
4741+ nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4742+ condim = solver.mjw_data.contact.dim.numpy()[:nacon]
4743+ for i in range(nacon):
4744+ self.assertEqual(int(condim[i]), 3)
4745+
4746+ def test_kf_zero_inverse_weight_leaves_solreffriction_unset(self):
4747+ """Verify a zero inverse weight cannot produce a non-finite reference."""
4748+ model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0)
4749+ solver.mjw_model.body_invweight0.zero_()
4750+ nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4751+ solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4752+ for i in range(nacon):
4753+ self.assertEqual(float(solreffriction[i][0]), 0.0)
4754+ self.assertEqual(float(solreffriction[i][1]), 0.0)
4755+
4756+ def test_kf_runtime_update(self):
4757+ """Verify runtime kf updates refresh the contact reference."""
4758+ model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0)
4759+ nacon, (collision_pipeline, contacts), (state_0, state_1, control) = self._step_and_read_solreffriction(
4760+ model, solver
4761+ )
4762+ model.shape_material_kf.fill_(400.0)
4763+ solver.notify_model_changed(ModelFlags.SHAPE_PROPERTIES)
4764+ collision_pipeline.collide(state_0, contacts)
4765+ solver.step(state_0, state_1, control, contacts, 1.0 / 240.0)
4766+ nacon = int(solver.mjw_data.nacon.numpy()[0])
4767+ self.assertGreater(nacon, 0)
4768+ solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4769+ expected = self._expected_solreffriction(solver, nacon, kf_pair=400.0)
4770+ for i in range(nacon):
4771+ self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4772+
4773+ def test_kf_impratio_scaling(self):
4774+ """Verify impratio scaling preserves the requested force-space slope."""
4775+ model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0, impratio=4.0)
4776+ nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4777+ solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4778+ expected = self._expected_solreffriction(solver, nacon, kf_pair=500.0, impratio=4.0)
4779+ for i in range(nacon):
4780+ self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4781+
4782+ def _slide_sphere_prismatic(self, kf, density, num_steps=60):
4783+ """Single contact, rotation locked by a prismatic joint: pure force-space viscous friction."""
4784+ builder = newton.ModelBuilder(gravity=(0.0, 0.0, -9.81))
4785+ builder.default_shape_cfg.ke = 1.0e5
4786+ builder.default_shape_cfg.kd = 1.0e3
4787+ builder.default_shape_cfg.kf = kf
4788+ builder.default_shape_cfg.mu = 2.0
4789+ builder.add_ground_plane()
4790+ radius = 0.1
4791+ # add_body() would auto-create a free joint; add_link()+add_articulation()
4792+ # keeps the prismatic joint as the body's sole (tree) connection, and the
4793+ # joint's parent_xform (not the link xform) is what places the body.
4794+ body = builder.add_link()
4795+ cfg = newton.ModelBuilder.ShapeConfig(density=density, ke=1.0e5, kd=1.0e3, kf=kf, mu=2.0)
4796+ builder.add_shape_sphere(body=body, radius=radius, cfg=cfg)
4797+ joint = builder.add_joint_prismatic(
4798+ parent=-1,
4799+ child=body,
4800+ parent_xform=wp.transform(wp.vec3(0.0, 0.0, radius - 0.02), wp.quat_identity()),
4801+ axis=(1.0, 0.0, 0.0),
4802+ )
4803+ builder.add_articulation([joint])
4804+ model = builder.finalize()
4805+ try:
4806+ solver = SolverMuJoCo(
4807+ model,
4808+ use_mujoco_contacts=False,
4809+ cone="elliptic",
4810+ solver="newton",
4811+ integrator="implicitfast",
4812+ iterations=50,
4813+ ls_iterations=20,
4814+ nconmax=32,
4815+ njmax=256,
4816+ )
4817+ except ImportError as e:
4818+ self.skipTest(f"MuJoCo or deps not installed. Skipping test: {e}")
4819+ state_0, state_1 = model.state(), model.state()
4820+ control = model.control()
4821+ collision_pipeline = newton.CollisionPipeline(model)
4822+ contacts = collision_pipeline.contacts()
4823+ joint_qd = state_0.joint_qd.numpy()
4824+ joint_qd[0] = 0.05
4825+ state_0.joint_qd.assign(joint_qd)
4826+ newton.eval_fk(model, state_0.joint_q, state_0.joint_qd, state_0)
4827+ for _ in range(num_steps):
4828+ state_0.clear_forces()
4829+ collision_pipeline.collide(state_0, contacts)
4830+ solver.step(state_0, state_1, control, contacts, 1.0 / 240.0)
4831+ state_0, state_1 = state_1, state_0
4832+ # step() only auto-syncs body_qd for free-joint bodies; read the DOF velocity
4833+ return float(state_0.joint_qd.numpy()[0])
4834+
4835+ def test_kf_force_space_mass_dependence(self):
4836+ """Verify the same kf produces mass-dependent velocity decay."""
4837+ v_light = self._slide_sphere_prismatic(kf=120.0, density=1000.0) # ~4.2 kg, rate ~9.9/s, analytic ~0.0042
4838+ v_heavy = self._slide_sphere_prismatic(kf=120.0, density=8000.0) # ~33.5 kg, rate ~1.2/s, analytic ~0.037
4839+ self.assertLess(v_light, 0.01)
4840+ self.assertGreater(v_heavy, 0.02)
4841+
4842+ def test_kf_scales_viscous_friction(self):
4843+ """Verify larger kf values produce faster sliding decay."""
4844+ v_soft = self._slide_sphere_prismatic(kf=30.0, density=1000.0) # rate ~2.5/s, analytic ~0.027
4845+ v_hard = self._slide_sphere_prismatic(kf=3000.0, density=1000.0) # rate ~247/s, analytic ~0
4846+ self.assertGreater(v_soft, 0.015)
4847+ self.assertLess(v_soft, 0.04)
4848+ self.assertGreater(v_soft, 3.0 * max(v_hard, 1.0e-6))
4849+
4850+ def test_kf_zero_preserves_sliding_velocity(self):
4851+ """Verify zero kf applies no sliding-friction force."""
4852+ velocity = self._slide_sphere_prismatic(kf=0.0, density=1000.0)
4853+ self.assertAlmostEqual(velocity, 0.05, places=5)
4854+
4855+
46614856class TestFrictionPriority(unittest.TestCase):
46624857 """Verify that contact friction respects geom priority.
46634858
0 commit comments