Skip to content

Commit a5ee16a

Browse files
committed
Refine cable self-collision resolution and coverage
Resolve welded rod-graph self-collision from the values member curves actually author instead of the resolver's injected default. Any authored newton:selfCollisionEnabled=False disables the whole graph, and the mixed authoring warning fires only when authored values genuinely disagree, so welding no longer lets an unauthored sibling's default override an authored value. This matches the single-curve and rigid-articulation paths. Skip the self-collision filter when the cable has collision disabled, so a non-colliding cable no longer emits filter pairs for shapes the broad phase never tests. Cover the enable_self_collisions argument, the physxArticulation fallback, the non-colliding-cable skip, and the authored-beats-default paths. Document cable self-collision resolution and the welded-graph rule, and note the enable_self_collisions argument in the changelog entry.
1 parent 35869ff commit a5ee16a

5 files changed

Lines changed: 125 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
- Fix masked `SolverCoupledProxy.reset()` calls clearing proxy feedback history for unselected worlds.
9292
- Fix hydroelastic primitive texture SDF generation to sample analytic primitive distances instead of temporary tessellated meshes. (#3239)
9393
- Fix MJCF, URDF, and USD imports rendering collision-only bodies as visuals when the asset authors visual geometry elsewhere. (#3291)
94-
- Fix `ModelBuilder.add_usd()` ignoring `newton:selfCollisionEnabled` on imported cables; a cable articulation with self-collision disabled now filters collisions between its non-adjacent segments.
94+
- Fix `ModelBuilder.add_usd()` ignoring cable self-collision settings: `newton:selfCollisionEnabled` on a cable prim and the `enable_self_collisions` argument now both apply, so a cable articulation with self-collision disabled filters collisions between its non-adjacent segments.
9595
- Fix `SchemaResolverPhysx` reading every D6 translational limit gain from the `linear` instance instead of its `transX`, `transY`, or `transZ` instance.
9696
- Fix USD capsule, cylinder, and cone visuals and sites without authored `radius`/`height` to use the UsdGeom schema fallbacks, matching collision shapes.
9797
- Fix `ViewerUSD` texture consumers observing partially written PNGs by publishing generated textures atomically (#3288)

docs/concepts/usd_parsing.rst

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,10 @@ Known gaps of the experimental importer, tracked as follow-ups:
144144
(dynamics only, per the proposal). Cloth and volume deformables cannot disable particle
145145
collision in Newton yet: they warn and import colliding. A welded cable graph shares one
146146
shape configuration, so any collision-enabled member curve makes the whole graph collide
147-
(mixed authoring warns).
147+
(mixed authoring warns). Self-collision resolves with the opposite polarity: one member
148+
curve authoring ``newton:selfCollisionEnabled = false`` disables self-collision for the
149+
whole graph (conflicting authored values warn), and once any member authors a value the
150+
``enable_self_collisions`` argument no longer applies to that graph.
148151
* **Collision and graphics geometry** -- separate collision or render geometry under a
149152
deformable body is not simulated or driven (embedding is not implemented): untagged
150153
PointBased graphics geometry warns and is skipped (a static import would leave a frozen
@@ -217,6 +220,12 @@ A welded rod graph gets one articulation per connected component; each of its cu
217220
own body range but shares that articulation. Attachment joints that tie a cable to other bodies
218221
close a loop, so they stay outside the articulation.
219222

223+
Self-collision is resolved per articulation from ``newton:selfCollisionEnabled`` authored on
224+
the curve prim itself (see the articulation remapping below). Disabling it filters collisions
225+
between the articulation's non-adjacent segments; adjacent segments are already filtered by the
226+
cable joints. Sibling curves of a multi-curve prim are separate articulations, so the filter
227+
never spans them.
228+
220229
.. code-block:: python
221230
222231
result = builder.add_usd("cables.usda", return_deformable_results=True)
@@ -359,7 +368,7 @@ The table below shows PhysX attribute remapping examples:
359368

360369
**Newton articulation remapping:**
361370

362-
On articulation root prims (with ``PhysicsArticulationRootAPI`` or ``NewtonArticulationRootAPI``), the following is resolved:
371+
On articulation root prims (with ``PhysicsArticulationRootAPI`` or ``NewtonArticulationRootAPI``) and on cable ``BasisCurves`` prims, the following is resolved:
363372

364373
.. list-table:: Newton Articulation Remapping
365374
:header-rows: 1

newton/_src/utils/import_usd_deformable_cable.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import math
1717
import warnings
1818
from dataclasses import replace
19+
from typing import TYPE_CHECKING
1920

2021
import warp as wp
2122

@@ -42,12 +43,16 @@
4243
_warn_unsupported_rest_fields,
4344
)
4445

46+
if TYPE_CHECKING:
47+
from ..sim.builder import ModelBuilder
4548

46-
def _apply_cable_self_collision_filter(builder, bodies, self_collision_enabled: bool) -> None:
49+
50+
def _apply_cable_self_collision_filter(builder: ModelBuilder, bodies: list[int], self_collision_enabled: bool) -> None:
4751
"""Filter all shape pairs among a cable articulation's bodies when self-collision is disabled.
4852
49-
Adjacent segments are already filtered by the CABLE joints (``collision_filter_parent``);
50-
this extends filtering to the non-adjacent pairs, mirroring the general rigid importer.
53+
Like the rigid importer's self-collision filtering, this emits every body pair, including
54+
the adjacent ones the CABLE joints already filter (``collision_filter_parent``); the
55+
duplicates are deduplicated when the filter pairs are consumed.
5156
"""
5257
if self_collision_enabled or len(bodies) < 2:
5358
return
@@ -434,28 +439,30 @@ def global_node(local: tuple[str, int]) -> int:
434439
body_frame_origin="com",
435440
)
436441

437-
# One rod graph is one articulation, so resolve self-collision per component: a welded graph
438-
# disables it if ANY member curve prim authors newton:selfCollisionEnabled=False.
439-
self_collision_states = {
440-
key: bool(
441-
ctx.resolver.get_value(
442-
curve_recs[key].prim,
443-
prim_type=PrimType.ARTICULATION,
444-
key="self_collision_enabled",
445-
default=ctx.enable_self_collisions,
446-
verbose=verbose,
447-
)
442+
# One rod graph is one articulation, so resolve self-collision from the values member
443+
# curves actually author (a None resolver means unauthored): any authored False disables
444+
# the graph, and unauthored curves stay neutral so welding cannot let a default override
445+
# an authored value. The default below only suppresses the unresolved-value diagnostic.
446+
authored_self_collisions: list[bool] = []
447+
for key in comp_paths:
448+
value, resolver = ctx.resolver.get_value_with_resolver(
449+
curve_recs[key].prim,
450+
prim_type=PrimType.ARTICULATION,
451+
key="self_collision_enabled",
452+
default=ctx.enable_self_collisions,
453+
verbose=verbose,
448454
)
449-
for key in comp_paths
450-
}
451-
graph_self_collision = all(self_collision_states.values())
452-
if not graph_self_collision and any(self_collision_states.values()):
455+
if resolver is not None:
456+
authored_self_collisions.append(bool(value))
457+
graph_self_collision = all(authored_self_collisions) if authored_self_collisions else ctx.enable_self_collisions
458+
if len(set(authored_self_collisions)) > 1:
453459
warnings.warn(
454460
f"cable graph '{cid}': welded cables mix self-collision-enabled and "
455461
f"self-collision-disabled curves; the whole graph disables self-collision.",
456462
stacklevel=2,
457463
)
458-
_apply_cable_self_collision_filter(builder, body_ids, graph_self_collision)
464+
if collision_enabled:
465+
_apply_cable_self_collision_filter(builder, body_ids, graph_self_collision)
459466

460467
# Partition graph bodies back to their owning curve, and rebuild the per-prim anchor
461468
# maps the curve-to-xform attachment pass reads (point index / segment index -> body).
@@ -674,7 +681,7 @@ def _deformable_import_cable(ctx: _DeformableImportContext, consumed_cable_curve
674681
prim_type=PrimType.ARTICULATION,
675682
key="self_collision_enabled",
676683
default=ctx.enable_self_collisions,
677-
verbose=ctx.verbose,
684+
verbose=verbose,
678685
)
679686
)
680687

@@ -769,8 +776,8 @@ def _deformable_import_cable(ctx: _DeformableImportContext, consumed_cable_curve
769776
wrap_in_articulation=True,
770777
body_frame_origin="com",
771778
)
772-
# This curve is its own articulation, so filter its own segments' self-collisions here.
773-
_apply_cable_self_collision_filter(builder, bodies, self_collision_enabled)
779+
if collision_enabled:
780+
_apply_cable_self_collision_filter(builder, bodies, self_collision_enabled)
774781
cable_bodies.extend(bodies)
775782
cable_joints.extend(joints)
776783
cable_point_runs.append((start, n, bodies))

newton/_src/utils/import_usd_deformable_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,6 @@ class _DeformableImportContext:
10231023
path_attachment_map: dict
10241024
path_attachment_attrs: dict
10251025
# Default for self-collision when an articulation authors no newton:selfCollisionEnabled.
1026-
enable_self_collisions: bool = True
1026+
enable_self_collisions: bool
10271027
# Filled by _scout_deformable_prims so the passes iterate buckets instead of the stage.
10281028
prims: _DeformablePrimBuckets = field(default_factory=_DeformablePrimBuckets)

newton/tests/test_import_usd_deformable_cable.py

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ class TestUSDDeformableCable(unittest.TestCase):
3131
"""Curve-deformable (cable) parsing into rods of capsule bodies + cable joints."""
3232

3333
@staticmethod
34-
def _author_attached_cable_pair(*, gap, stiffness=None, damping=None):
34+
def _author_attached_cable_pair(*, gap, stiffness=None, damping=None, collision=True):
3535
"""Two 4-point cables separated by ``gap`` in y with a point->point attachment
3636
(P0 of B onto P0 of A); returns the stage."""
3737
stage = _deformable_stage()
3838
pts_a = [(0.0, 0.0, 1.0), (0.1, 0.0, 1.0), (0.2, 0.0, 1.0), (0.3, 0.0, 1.0)]
3939
pts_b = [(0.0, gap, 1.0), (0.1, gap, 1.0), (0.2, gap, 1.0), (0.3, gap, 1.0)]
40-
_add_cable_curve(stage, "/World/CableA", pts_a)
41-
_add_cable_curve(stage, "/World/CableB", pts_b)
40+
_add_cable_curve(stage, "/World/CableA", pts_a, collision=collision)
41+
_add_cable_curve(stage, "/World/CableB", pts_b, collision=collision)
4242
_add_physics_attachment(
4343
stage,
4444
"/World/Junction",
@@ -53,6 +53,13 @@ def _author_attached_cable_pair(*, gap, stiffness=None, damping=None):
5353
)
5454
return stage
5555

56+
@staticmethod
57+
def _shape_pair(builder, body_a, body_b):
58+
"""Canonical (min, max) shape-filter pair for the first shapes of two bodies."""
59+
s1 = builder.body_shapes[body_a][0]
60+
s2 = builder.body_shapes[body_b][0]
61+
return (min(s1, s2), max(s1, s2))
62+
5663
def test_attachment_weld_policy_rejects_compliant_or_apart(self):
5764
"""A curve-to-curve attachment welds only when hard AND coincident: a compliant
5865
(zero or finite stiffness) or spatially-apart junction leaves two independent
@@ -947,72 +954,120 @@ def test_cable_self_collision_disabled_filters_non_adjacent_pairs(self):
947954
"""newton:selfCollisionEnabled=False filters non-adjacent cable segment shape pairs.
948955
949956
Adjacent segments are already filtered by the CABLE joints; the flag extends the
950-
filtering to non-adjacent pairs, mirroring the general rigid importer. The default
951-
(flag True or unauthored) leaves those pairs unfiltered.
957+
filtering to non-adjacent pairs, mirroring the general rigid importer. An authored value
958+
beats the ``enable_self_collisions`` argument, which only applies when the cable authors
959+
nothing, and a non-colliding cable emits no filter pairs at all.
952960
"""
953961
from pxr import Sdf
954962

955963
# 5 points -> 4 segment bodies, so there are non-adjacent pairs to check.
956964
pts = [(0.0, 0.0, 1.0), (0.1, 0.0, 1.0), (0.2, 0.0, 1.0), (0.3, 0.0, 1.0), (0.4, 0.0, 1.0)]
957-
for case, self_collision in (("disabled", False), ("enabled", True), ("unauthored", None)):
965+
for case, authored, default, collision, expect_filtered in (
966+
("authored_disabled", False, True, True, True),
967+
("unauthored", None, True, True, False),
968+
("default_disabled", None, False, True, True),
969+
("authored_beats_default", True, False, True, False),
970+
("non_colliding", False, True, False, False),
971+
):
958972
with self.subTest(case=case):
959973
stage = _deformable_stage()
960-
curve = _add_cable_curve(stage, "/World/Cable", pts)
961-
if self_collision is not None:
974+
curve = _add_cable_curve(stage, "/World/Cable", pts, collision=collision)
975+
if authored is not None:
962976
curve.GetPrim().CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(
963-
self_collision
977+
authored
964978
)
965979
builder = newton.ModelBuilder()
966-
builder.add_usd(stage)
980+
builder.add_usd(stage, enable_self_collisions=default)
967981
b0, b1 = group_range(builder, "cable", "/World/Cable", "body")
968982
self.assertEqual(b1 - b0, 4)
969983
# A non-adjacent segment pair (bodies b0 and b0+2).
970-
s1 = builder.body_shapes[b0][0]
971-
s2 = builder.body_shapes[b0 + 2][0]
972-
pair = (min(s1, s2), max(s1, s2))
984+
pair = self._shape_pair(builder, b0, b0 + 2)
973985
pairs = set(builder.shape_collision_filter_pairs)
974-
if case == "disabled":
986+
if expect_filtered:
975987
self.assertIn(pair, pairs)
976988
else:
977989
self.assertNotIn(pair, pairs)
990+
if not collision:
991+
# Non-colliding shapes never reach the broad phase, so the filter is skipped
992+
# entirely (a colliding cable always emits the adjacent joint pairs).
993+
self.assertEqual(pairs, set())
994+
995+
def test_cable_self_collision_physx_fallback_needs_resolver(self):
996+
"""physxArticulation:enabledSelfCollisions gates a cable only with the PhysX resolver.
997+
998+
The default resolver list is Newton-only, so the PhysX articulation attribute is
999+
silently ignored unless the caller opts in via ``schema_resolvers``.
1000+
"""
1001+
from pxr import Sdf
1002+
1003+
# 5 points -> 4 segment bodies, so there is a non-adjacent pair to check.
1004+
pts = [(0.0, 0.0, 1.0), (0.1, 0.0, 1.0), (0.2, 0.0, 1.0), (0.3, 0.0, 1.0), (0.4, 0.0, 1.0)]
1005+
1006+
def non_adjacent_pair_filtered(**add_usd_kwargs):
1007+
stage = _deformable_stage()
1008+
curve = _add_cable_curve(stage, "/World/Cable", pts)
1009+
curve.GetPrim().CreateAttribute("physxArticulation:enabledSelfCollisions", Sdf.ValueTypeNames.Bool).Set(
1010+
False
1011+
)
1012+
builder = newton.ModelBuilder()
1013+
builder.add_usd(stage, **add_usd_kwargs)
1014+
b0, _ = group_range(builder, "cable", "/World/Cable", "body")
1015+
return self._shape_pair(builder, b0, b0 + 2) in set(builder.shape_collision_filter_pairs)
1016+
1017+
self.assertTrue(non_adjacent_pair_filtered(schema_resolvers=[SchemaResolverPhysx()]))
1018+
self.assertFalse(non_adjacent_pair_filtered())
9781019

9791020
def test_welded_graph_self_collision_disabled_filters_non_adjacent_pairs(self):
9801021
"""A welded cable graph honors newton:selfCollisionEnabled=False on any member curve.
9811022
9821023
Disabling self-collision on one welded member filters non-adjacent segment shape pairs
9831024
across the whole graph articulation (both within a curve and between welded curves), and
984-
the importer warns that the mixed authoring resolves to self-collision disabled.
1025+
the importer warns only when members author disagreeing values. Only authored values
1026+
count, so an unauthored sibling never lets the ``enable_self_collisions`` default
1027+
override them, and a non-colliding graph emits no filter pairs at all.
9851028
"""
9861029
from pxr import Sdf
9871030

988-
for case, author in (("disabled", True), ("default", False)):
1031+
for case, author_a, author_b, default, collision, expect_filtered in (
1032+
("disabled", False, None, True, True, True),
1033+
("all_disabled", False, False, True, True, True),
1034+
("mixed", False, True, True, True, True),
1035+
("default", None, None, True, True, False),
1036+
("authored_beats_default", True, None, False, True, False),
1037+
("non_colliding", False, None, True, False, False),
1038+
):
9891039
with self.subTest(case=case):
9901040
# Hard, coincident junction welds CableA and CableB into one graph articulation.
991-
stage = self._author_attached_cable_pair(gap=0.0)
992-
if author:
993-
stage.GetPrimAtPath("/World/CableA").CreateAttribute(
994-
"newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool
995-
).Set(False)
1041+
stage = self._author_attached_cable_pair(gap=0.0, collision=collision)
1042+
for path, authored in (("/World/CableA", author_a), ("/World/CableB", author_b)):
1043+
if authored is not None:
1044+
stage.GetPrimAtPath(path).CreateAttribute(
1045+
"newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool
1046+
).Set(authored)
9961047
builder = newton.ModelBuilder()
997-
if author:
998-
# Members disagree (CableB unauthored -> default True), so the importer warns.
999-
with self.assertWarnsRegex(UserWarning, "disables self-collision"):
1000-
builder.add_usd(stage)
1001-
else:
1002-
builder.add_usd(stage)
1048+
with warnings.catch_warnings(record=True) as caught:
1049+
warnings.simplefilter("always")
1050+
builder.add_usd(stage, enable_self_collisions=default)
1051+
# Only genuinely disagreeing authored values warn.
1052+
warned = any("disables self-collision" in str(w.message) for w in caught)
1053+
self.assertEqual(warned, case == "mixed")
10031054
self.assertEqual(builder.articulation_count, 1)
10041055
a0, _ = group_range(builder, "cable", "/World/CableA", "body")
10051056
b0, _ = group_range(builder, "cable", "/World/CableB", "body")
10061057
pairs = set(builder.shape_collision_filter_pairs)
10071058
# A within-curve non-adjacent pair and a cross-curve non-adjacent pair.
10081059
within = self._shape_pair(builder, a0, a0 + 2)
10091060
cross = self._shape_pair(builder, a0 + 2, b0 + 2)
1010-
if case == "disabled":
1061+
if expect_filtered:
10111062
self.assertIn(within, pairs)
10121063
self.assertIn(cross, pairs)
10131064
else:
10141065
self.assertNotIn(within, pairs)
10151066
self.assertNotIn(cross, pairs)
1067+
if not collision:
1068+
# Non-colliding shapes never reach the broad phase, so the filter is skipped
1069+
# entirely (a colliding graph always emits the adjacent joint pairs).
1070+
self.assertEqual(pairs, set())
10161071

10171072
def test_self_collision_disabled_does_not_filter_across_curves_in_one_prim(self):
10181073
"""A multi-curve BasisCurves prim filters each curve's own segments, not across curves.
@@ -1058,13 +1113,6 @@ def test_self_collision_disabled_does_not_filter_across_curves_in_one_prim(self)
10581113
# Across the two curves (separate articulations), nothing is filtered.
10591114
self.assertNotIn(self._shape_pair(builder, b0, b0 + 3), pairs)
10601115

1061-
@staticmethod
1062-
def _shape_pair(builder, body_a, body_b):
1063-
"""Canonical (min, max) shape-filter pair for the first shapes of two bodies."""
1064-
s1 = builder.body_shapes[body_a][0]
1065-
s2 = builder.body_shapes[body_b][0]
1066-
return (min(s1, s2), max(s1, s2))
1067-
10681116
def test_neg_inf_junction_stiffness_does_not_weld(self):
10691117
"""-inf is the material sentinel, not the attachment one (+inf = hard): a
10701118
junction authoring -inf stiffness is nonconforming and must not weld the

0 commit comments

Comments
 (0)