Skip to content

Commit ca97d53

Browse files
committed
Gracefully degrade full-surface contacts
1 parent c13e9ed commit ca97d53

6 files changed

Lines changed: 90 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
### Changed
5050

5151
- Decide collider visibility from USD `purpose` and visibility rather than from a bound render material. A collider whose `purpose` resolves to `default` is viewport geometry and is drawn; mark it `guide` to state that it is collision-only. Previously an unrelated visual elsewhere in the scene could make a collider vanish. `force_show_colliders` and `hide_collision_shapes` are unchanged.
52-
- Preserve full-surface (edge/face) soft-contact records through `SolverCoupled`'s per-entry contact filter, which previously dropped them because it keyed on a single particle id. A record is kept only when the entry owns every corner it references, so one spanning two entries is dropped by both. Coupled sub-solvers that do not consume edge/face records (`SolverXPBD`, `SolverSemiImplicit`, `SolverStyle3D`) now raise instead of silently running with those records removed; build their collision pipeline with `enable_rigid_soft_full_surface_contact=False`.
52+
- Preserve full-surface (edge/face) soft-contact records for capable `SolverCoupled` entries, and gracefully drop them for sub-solvers that consume only particle contacts. A record is kept only when the entry owns every corner it references, so one spanning two entries is dropped by both.
5353
- Disable the implicit positive Dahl-friction defaults in `SolverVBD.register_custom_attributes()` (deprecated in 1.3.0): `vbd:dahl_eps_max` and `vbd:dahl_tau` now default to zero, and Dahl cable friction is enabled only where both are authored positive. Pass `dahl_defaults_enabled=True` to temporarily restore the old defaults; the compatibility mode will be removed in a future release.
5454
- Keep the authored render mesh when `ModelBuilder.add_usd()` approximates a collider. `physics:approximation` is scoped to collision, so a Mesh that is both render geometry and a collider now imports as an approximated collision shape plus a visual shape carrying the original topology, instead of replacing the render mesh with the approximation. This raises `Model.shape_count` for such prims: iterate on `ShapeFlags.COLLIDE_SHAPES` rather than assuming one shape per collider prim. The visual shape adds no mass and no collision, appends after the originals so existing shape indices and `path_shape_map` entries are unchanged, and is skipped when `load_visual_shapes=False`.
5555
- Compile tiled camera render kernels with CUDA fast math by default for faster rendering; set `SensorTiledCamera.render_config.enable_fast_math = False` for bit-exact, IEEE-precise output.

docs/concepts/coupling.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,10 @@ The coupled-solver framework is useful today, but it is still experimental:
377377
- USD ownership, automatic coupled-solver construction, and high-level tuning
378378
guidance are not part of the experimental public API yet.
379379
- Full-surface (edge/face) rigid-soft contacts are consumed by
380-
:class:`~newton.solvers.SolverVBD` only; other sub-solvers raise if handed
381-
them. The per-entry contact filter keeps such a record only when the entry owns
382-
every corner it references, so one spanning two entries is dropped by both.
383-
Proxy particles do not support them at all.
380+
:class:`~newton.solvers.SolverVBD` only. The per-entry contact filter drops
381+
them for other sub-solvers while preserving particle contacts, and keeps them
382+
for VBD only when the entry owns every referenced corner. A record spanning
383+
two entries is dropped by both. Proxy particles do not support them at all.
384384

385385
Treat coupled solvers as an advanced feature for controlled experiments and
386386
solver integration work. Prefer focused regression tests and explicit scene

newton/_src/solvers/coupled/interface.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ def coupling_notify_input_state_update(state, flags, *, iteration_restart=False,
4444
def coupling_supports_inertial_property_refresh() -> bool: ...
4545
4646
47+
def coupling_supports_full_surface_soft_contacts() -> bool: ...
48+
49+
4750
def coupling_rewind_proxy_body(
4851
body_local_to_proxy_global, state, coupling_forces, body_gravity_acceleration, dt
4952
) -> None: ...
@@ -252,6 +255,10 @@ def coupling_supports_inertial_property_refresh(self) -> bool:
252255
"""
253256
return False
254257

258+
def coupling_supports_full_surface_soft_contacts(self) -> bool:
259+
"""Return whether the solver consumes edge and face soft contacts."""
260+
return False
261+
255262
def coupling_eval_gravity_acceleration(
256263
self,
257264
out_body_acceleration: wp.array[wp.vec3] | None,

newton/_src/solvers/coupled/solver_coupled.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2305,6 +2305,7 @@ def _contacts_for_entry(self, entry: SolverEntry, contacts: Contacts | None) ->
23052305
return contacts
23062306

23072307
filtered = self._ensure_entry_contact_buffer(entry, contacts)
2308+
keep_full_surface_contacts = filtered._enable_rigid_soft_full_surface_contact
23082309
force_contact_update = int(self._entry_contact_sources.get(entry.name) is not contacts)
23092310
if force_contact_update:
23102311
self._entry_contact_sources[entry.name] = contacts
@@ -2443,6 +2444,7 @@ def _contacts_for_entry(self, entry: SolverEntry, contacts: Contacts | None) ->
24432444
entry.view.particle_flags,
24442445
int(ShapeFlags.COLLIDE_PARTICLES),
24452446
int(ParticleFlags.ACTIVE),
2447+
int(keep_full_surface_contacts),
24462448
filtered.soft_contact_count,
24472449
filtered.soft_contact_particle,
24482450
filtered.soft_contact_shape,
@@ -2510,9 +2512,10 @@ def _ensure_entry_contact_buffer(self, entry: SolverEntry, contacts: Contacts) -
25102512
dtype=wp.int32,
25112513
device=contacts.device,
25122514
)
2513-
# Carry the capability marker: the filter now preserves edge/face records, so a sub-solver
2514-
# that cannot consume them must still be able to reject the buffer it is handed.
2515-
filtered._enable_rigid_soft_full_surface_contact = contacts._enable_rigid_soft_full_surface_contact
2515+
filtered._enable_rigid_soft_full_surface_contact = bool(
2516+
contacts._enable_rigid_soft_full_surface_contact
2517+
and entry.solver.coupling_supports_full_surface_soft_contacts()
2518+
)
25162519
return filtered
25172520

25182521
@staticmethod
@@ -3279,6 +3282,7 @@ def _filter_soft_contacts_global_shape_ids_kernel(
32793282
particle_flags: wp.array[wp.int32],
32803283
collide_particles_mask: int,
32813284
active_particle_mask: int,
3285+
keep_full_surface_contacts: int,
32823286
dst_count: wp.array[wp.int32],
32833287
dst_particle: wp.array[int],
32843288
dst_shape: wp.array[int],
@@ -3299,14 +3303,14 @@ def _filter_soft_contacts_global_shape_ids_kernel(
32993303

33003304
particle = src_particle[contact_id]
33013305
shape = src_shape[contact_id]
3306+
if particle < 0 and keep_full_surface_contacts == 0:
3307+
return
33023308
if shape < 0 or shape >= shape_flags.shape[0]:
33033309
return
33043310
if (shape_flags[shape] & collide_particles_mask) == 0:
33053311
return
33063312

3307-
# Validate every soft corner the record references: one for a particle record, two for an edge,
3308-
# three for a face. Requiring all of them to be owned and active keeps a record whose corners
3309-
# straddle two entries out of both, rather than letting one entry read a particle it does not own.
3313+
# Keep records only when the entry owns every referenced corner.
33103314
corners = src_indices[contact_id]
33113315
if corners[0] < 0:
33123316
return
@@ -3327,8 +3331,6 @@ def _filter_soft_contacts_global_shape_ids_kernel(
33273331
dst_body_vel[dst_id] = src_body_vel[contact_id]
33283332
dst_normal[dst_id] = src_normal[contact_id]
33293333
dst_tids[dst_id] = src_tids[contact_id]
3330-
# Carry the unified feature record too (the particle-only path writes (p, -1, -1) + (1, 0, 0)); VBD
3331-
# reads these fields, so dropping them delivers the contact as (-1, -1, -1) and regresses coupled
3332-
# VBD even with full-surface contact off (E7).
3334+
# VBD consumes the unified particle, edge, and face representation.
33333335
dst_indices[dst_id] = src_indices[contact_id]
33343336
dst_barycentric[dst_id] = src_barycentric[contact_id]

newton/_src/solvers/vbd/solver_vbd.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,10 @@ def notify_model_changed(self, flags: ModelFlags | int) -> None:
867867
def coupling_supports_inertial_property_refresh(self) -> bool:
868868
return True
869869

870+
@override
871+
def coupling_supports_full_surface_soft_contacts(self) -> bool:
872+
return True
873+
870874
def coupling_notify_input_state_update(
871875
self,
872876
state: State,
@@ -1055,7 +1059,6 @@ def coupling_harvest_proxy_particle_forces(
10551059
if self.model.particle_count == 0 or particle_local_to_proxy_global.shape[0] == 0:
10561060
return
10571061

1058-
# Reaching here means proxy particles exist.
10591062
if contacts is not None:
10601063
contacts._assert_particle_only_soft_contacts("SolverVBD proxy-particle coupling")
10611064

newton/tests/test_coupled_solver.py

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ def step(self, state_in, state_out, control, contacts, dt):
124124
wp.copy(state_out.joint_qd, state_in.joint_qd)
125125

126126

127+
class _FullSurfaceControlRecordingSolver(_ControlRecordingSolver):
128+
"""Test solver that accepts full-surface soft contacts."""
129+
130+
def coupling_supports_full_surface_soft_contacts(self) -> bool:
131+
return True
132+
133+
127134
class _InPlaceRecordingParticleSolver(SolverBase, CouplingInterface):
128135
"""Test solver that records whether it was stepped in-place."""
129136

@@ -903,24 +910,30 @@ def test_compaction_fallback_reports_reason(self):
903910
self.assertEqual(coupled.view("child").body_count, model.body_count)
904911

905912
@staticmethod
906-
def _seeded_full_surface_face_contact(model, corners):
907-
"""A contacts buffer holding a single soft FACE record over ``corners``."""
913+
def _seeded_full_surface_contacts(model, corners, particle=None):
914+
"""Build contacts with a face record and an optional particle record."""
908915
pipeline = newton.CollisionPipeline(
909916
model, broad_phase="nxn", soft_contact_margin=0.1, enable_rigid_soft_full_surface_contact=True
910917
)
911918
contacts = pipeline.contacts()
912919

913-
def _set(arr, value):
920+
def _set(arr, index, value):
914921
a = arr.numpy()
915-
a[0] = value
922+
a[index] = value
916923
arr.assign(a)
917924

918-
contacts.soft_contact_count.assign([1])
919-
_set(contacts.soft_contact_particle, -1) # edge/face records carry no single particle id
920-
_set(contacts.soft_contact_indices, list(corners))
921-
_set(contacts.soft_contact_barycentric, [0.6, 0.3, 0.1])
922-
_set(contacts.soft_contact_shape, 0)
923-
_set(contacts.soft_contact_normal, [0.0, 0.0, 1.0])
925+
contacts.soft_contact_count.assign([1 + int(particle is not None)])
926+
_set(contacts.soft_contact_particle, 0, -1)
927+
_set(contacts.soft_contact_indices, 0, list(corners))
928+
_set(contacts.soft_contact_barycentric, 0, [0.6, 0.3, 0.1])
929+
_set(contacts.soft_contact_shape, 0, 0)
930+
_set(contacts.soft_contact_normal, 0, [0.0, 0.0, 1.0])
931+
if particle is not None:
932+
_set(contacts.soft_contact_particle, 1, particle)
933+
_set(contacts.soft_contact_indices, 1, [particle, -1, -1])
934+
_set(contacts.soft_contact_barycentric, 1, [1.0, 0.0, 0.0])
935+
_set(contacts.soft_contact_shape, 1, 0)
936+
_set(contacts.soft_contact_normal, 1, [0.0, 0.0, 1.0])
924937
return contacts
925938

926939
@staticmethod
@@ -935,18 +948,18 @@ def _build_box_and_triangle():
935948
return builder.finalize(device="cpu"), body, joint, particles
936949

937950
def test_full_surface_records_survive_the_entry_filter(self):
938-
"""The per-entry soft-contact filter keeps an edge/face record whose corners it owns.
939-
940-
The filter validates every corner the record references rather than a single particle id,
941-
so a face record reaches a sub-solver that owns all three of its corners.
942-
"""
951+
"""Keep a full-surface record owned by a capable entry."""
943952
model, body, joint, particles = self._build_box_and_triangle()
944-
contacts = self._seeded_full_surface_face_contact(model, particles)
953+
contacts = self._seeded_full_surface_contacts(model, particles)
945954
coupled = SolverCoupled(
946955
model=model,
947956
entries=[
948957
SolverCoupled.Entry(
949-
name="A", solver=_ControlRecordingSolver, bodies=[body], joints=[joint], particles=particles
958+
name="A",
959+
solver=_FullSurfaceControlRecordingSolver,
960+
bodies=[body],
961+
joints=[joint],
962+
particles=particles,
950963
)
951964
],
952965
)
@@ -959,20 +972,20 @@ def test_full_surface_records_survive_the_entry_filter(self):
959972
self.assertTrue(filtered._enable_rigid_soft_full_surface_contact, "capability marker must be carried over")
960973

961974
def test_full_surface_records_straddling_entries_are_dropped(self):
962-
"""A record whose corners span two entries is dropped by both.
963-
964-
Keeping it would let an entry evaluate a contact point from a particle it does not own, so
965-
the filter requires every referenced corner to be owned and active.
966-
"""
975+
"""Drop a full-surface record spanning two capable entries."""
967976
model, body, joint, particles = self._build_box_and_triangle()
968-
contacts = self._seeded_full_surface_face_contact(model, particles)
977+
contacts = self._seeded_full_surface_contacts(model, particles)
969978
coupled = SolverCoupled(
970979
model=model,
971980
entries=[
972981
SolverCoupled.Entry(
973-
name="A", solver=_ControlRecordingSolver, bodies=[body], joints=[joint], particles=particles[:2]
982+
name="A",
983+
solver=_FullSurfaceControlRecordingSolver,
984+
bodies=[body],
985+
joints=[joint],
986+
particles=particles[:2],
974987
),
975-
SolverCoupled.Entry(name="B", solver=_ControlRecordingSolver, particles=particles[2:]),
988+
SolverCoupled.Entry(name="B", solver=_FullSurfaceControlRecordingSolver, particles=particles[2:]),
976989
],
977990
)
978991

@@ -982,6 +995,30 @@ def test_full_surface_records_straddling_entries_are_dropped(self):
982995
count = int(coupled._entry_contact_buffers[name].soft_contact_count.numpy()[0])
983996
self.assertEqual(count, 0, f"entry {name} owns only part of the record and must drop it")
984997

998+
def test_full_surface_contacts_degrade_per_entry(self):
999+
"""Keep only particle contacts for an unsupported entry."""
1000+
model, body, joint, particles = self._build_box_and_triangle()
1001+
contacts = self._seeded_full_surface_contacts(model, particles, particle=particles[0])
1002+
coupled = SolverCoupled(
1003+
model=model,
1004+
entries=[
1005+
SolverCoupled.Entry(
1006+
name="particle_only",
1007+
solver=_ControlRecordingSolver,
1008+
bodies=[body],
1009+
joints=[joint],
1010+
particles=particles,
1011+
),
1012+
],
1013+
)
1014+
1015+
coupled.step(model.state(), model.state(), None, contacts, dt=1.0 / 60.0)
1016+
1017+
particle_only = coupled._entry_contact_buffers["particle_only"]
1018+
self.assertEqual(int(particle_only.soft_contact_count.numpy()[0]), 1)
1019+
self.assertEqual(int(particle_only.soft_contact_particle.numpy()[0]), particles[0])
1020+
self.assertFalse(particle_only._enable_rigid_soft_full_surface_contact)
1021+
9851022
def test_entry_control_arrays_are_mapped_to_local_dofs(self):
9861023
"""Entry solvers should receive control arrays in their local DOF namespace."""
9871024
_ControlRecordingSolver.instances.clear()
@@ -3280,6 +3317,7 @@ def _coupled_soft_contact_filter_preserves_unified_fields(test, device):
32803317
wp.array([int(ParticleFlags.ACTIVE)], dtype=wp.int32, device=device), # particle_flags
32813318
int(ShapeFlags.COLLIDE_PARTICLES),
32823319
int(ParticleFlags.ACTIVE),
3320+
1, # keep_full_surface_contacts
32833321
wp.zeros(1, dtype=wp.int32, device=device), # dst_count
32843322
wp.full(1, -1, dtype=wp.int32, device=device), # dst_particle
32853323
wp.full(1, -1, dtype=wp.int32, device=device), # dst_shape

0 commit comments

Comments
 (0)