Skip to content

Commit 35869ff

Browse files
committed
Honor selfCollisionEnabled on imported cables
The USD cable importer built rod articulations via add_rod / add_rod_graph but never resolved the per-articulation self-collision flag, so authoring newton:selfCollisionEnabled on a cable had no effect while the general rigid importer honored it. This left non-adjacent segments of the same cable colliding even when self-collision was disabled. Resolve self_collision_enabled per cable articulation (newton namespace, with the physxArticulation fallback) and, when disabled, filter collisions between all of the cable's segment shape pairs, mirroring the rigid importer. Welded rod graphs disable self-collision when any member curve opts out, and warn on mixed authoring.
1 parent d124e5b commit 35869ff

5 files changed

Lines changed: 178 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +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.
9495
- Fix `SchemaResolverPhysx` reading every D6 translational limit gain from the `linear` instance instead of its `transX`, `transY`, or `transZ` instance.
9596
- Fix USD capsule, cylinder, and cone visuals and sites without authored `radius`/`height` to use the UsdGeom schema fallbacks, matching collision shapes.
9697
- Fix `ViewerUSD` texture consumers observing partially written PNGs by publishing generated textures atomically (#3288)

newton/_src/utils/import_usd.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4216,6 +4216,7 @@ def initialize_free_joint_velocities() -> None:
42164216
linear_unit=linear_unit,
42174217
ignore_paths=ignore_paths,
42184218
verbose=verbose,
4219+
enable_self_collisions=enable_self_collisions,
42194220
path_body_map=path_body_map,
42204221
path_shape_map=path_shape_map,
42214222
path_cable_map=path_cable_map,

newton/_src/utils/import_usd_deformable_cable.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212

1313
from __future__ import annotations
1414

15+
import itertools
1516
import math
1617
import warnings
1718
from dataclasses import replace
1819

1920
import warp as wp
2021

22+
from ..usd.schema_resolver import PrimType
2123
from .import_usd_deformable_utils import (
2224
_DEFAULT_CABLE_RADIUS,
2325
_apply_cable_masses,
@@ -41,6 +43,20 @@
4143
)
4244

4345

46+
def _apply_cable_self_collision_filter(builder, bodies, self_collision_enabled: bool) -> None:
47+
"""Filter all shape pairs among a cable articulation's bodies when self-collision is disabled.
48+
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.
51+
"""
52+
if self_collision_enabled or len(bodies) < 2:
53+
return
54+
for b1, b2 in itertools.combinations(bodies, 2):
55+
for s1 in builder.body_shapes[b1]:
56+
for s2 in builder.body_shapes[b2]:
57+
builder.add_shape_collision_filter_pair(s1, s2)
58+
59+
4460
def _read_validated_curve_topology(curves, path: str, *, warn: bool = True):
4561
"""Read a cable prim's ``points`` / ``curveVertexCounts`` after validating the partition.
4662
@@ -418,6 +434,29 @@ def global_node(local: tuple[str, int]) -> int:
418434
body_frame_origin="com",
419435
)
420436

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+
)
448+
)
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()):
453+
warnings.warn(
454+
f"cable graph '{cid}': welded cables mix self-collision-enabled and "
455+
f"self-collision-disabled curves; the whole graph disables self-collision.",
456+
stacklevel=2,
457+
)
458+
_apply_cable_self_collision_filter(builder, body_ids, graph_self_collision)
459+
421460
# Partition graph bodies back to their owning curve, and rebuild the per-prim anchor
422461
# maps the curve-to-xform attachment pass reads (point index / segment index -> body).
423462
per_prim_segments: dict[str, dict[int, tuple[int, float]]] = {}
@@ -627,6 +666,17 @@ def _deformable_import_cable(ctx: _DeformableImportContext, consumed_cable_curve
627666
has_shape_collision=collision_enabled,
628667
has_particle_collision=collision_enabled,
629668
)
669+
# Each curve in this prim becomes its own articulation via add_rod below, so resolve the
670+
# prim's self-collision flag once and apply the filter per curve (not across sibling curves).
671+
self_collision_enabled = bool(
672+
ctx.resolver.get_value(
673+
prim,
674+
prim_type=PrimType.ARTICULATION,
675+
key="self_collision_enabled",
676+
default=ctx.enable_self_collisions,
677+
verbose=ctx.verbose,
678+
)
679+
)
630680

631681
cable_bodies: list[int] = []
632682
cable_joints: list[int] = []
@@ -719,6 +769,8 @@ def _deformable_import_cable(ctx: _DeformableImportContext, consumed_cable_curve
719769
wrap_in_articulation=True,
720770
body_frame_origin="com",
721771
)
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)
722774
cable_bodies.extend(bodies)
723775
cable_joints.extend(joints)
724776
cable_point_runs.append((start, n, bodies))

newton/_src/utils/import_usd_deformable_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,5 +1022,7 @@ class _DeformableImportContext:
10221022
path_soft_attrs: dict
10231023
path_attachment_map: dict
10241024
path_attachment_attrs: dict
1025+
# Default for self-collision when an articulation authors no newton:selfCollisionEnabled.
1026+
enable_self_collisions: bool = True
10251027
# Filled by _scout_deformable_prims so the passes iterate buckets instead of the stage.
10261028
prims: _DeformablePrimBuckets = field(default_factory=_DeformablePrimBuckets)

newton/tests/test_import_usd_deformable_cable.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,128 @@ def test_cable_collision_gating(self):
943943
self.assertEqual(is_colliding, expected_colliding, f"shape {i}")
944944
builder.finalize()
945945

946+
def test_cable_self_collision_disabled_filters_non_adjacent_pairs(self):
947+
"""newton:selfCollisionEnabled=False filters non-adjacent cable segment shape pairs.
948+
949+
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.
952+
"""
953+
from pxr import Sdf
954+
955+
# 5 points -> 4 segment bodies, so there are non-adjacent pairs to check.
956+
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)):
958+
with self.subTest(case=case):
959+
stage = _deformable_stage()
960+
curve = _add_cable_curve(stage, "/World/Cable", pts)
961+
if self_collision is not None:
962+
curve.GetPrim().CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(
963+
self_collision
964+
)
965+
builder = newton.ModelBuilder()
966+
builder.add_usd(stage)
967+
b0, b1 = group_range(builder, "cable", "/World/Cable", "body")
968+
self.assertEqual(b1 - b0, 4)
969+
# 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))
973+
pairs = set(builder.shape_collision_filter_pairs)
974+
if case == "disabled":
975+
self.assertIn(pair, pairs)
976+
else:
977+
self.assertNotIn(pair, pairs)
978+
979+
def test_welded_graph_self_collision_disabled_filters_non_adjacent_pairs(self):
980+
"""A welded cable graph honors newton:selfCollisionEnabled=False on any member curve.
981+
982+
Disabling self-collision on one welded member filters non-adjacent segment shape pairs
983+
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.
985+
"""
986+
from pxr import Sdf
987+
988+
for case, author in (("disabled", True), ("default", False)):
989+
with self.subTest(case=case):
990+
# 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)
996+
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)
1003+
self.assertEqual(builder.articulation_count, 1)
1004+
a0, _ = group_range(builder, "cable", "/World/CableA", "body")
1005+
b0, _ = group_range(builder, "cable", "/World/CableB", "body")
1006+
pairs = set(builder.shape_collision_filter_pairs)
1007+
# A within-curve non-adjacent pair and a cross-curve non-adjacent pair.
1008+
within = self._shape_pair(builder, a0, a0 + 2)
1009+
cross = self._shape_pair(builder, a0 + 2, b0 + 2)
1010+
if case == "disabled":
1011+
self.assertIn(within, pairs)
1012+
self.assertIn(cross, pairs)
1013+
else:
1014+
self.assertNotIn(within, pairs)
1015+
self.assertNotIn(cross, pairs)
1016+
1017+
def test_self_collision_disabled_does_not_filter_across_curves_in_one_prim(self):
1018+
"""A multi-curve BasisCurves prim filters each curve's own segments, not across curves.
1019+
1020+
Every curveVertexCounts entry becomes its own articulation via add_rod, so disabling
1021+
self-collision must filter within a curve while leaving independent sibling curves in
1022+
the same prim free to collide.
1023+
"""
1024+
from pxr import Sdf, UsdGeom
1025+
1026+
stage = _deformable_stage()
1027+
curves = UsdGeom.BasisCurves.Define(stage, "/World/Cable")
1028+
curves.CreateTypeAttr().Set(UsdGeom.Tokens.linear)
1029+
# Two independent 4-point curves (3 segment bodies each) in one prim.
1030+
curves.CreatePointsAttr(
1031+
[
1032+
(0.0, 0.0, 1.0),
1033+
(0.1, 0.0, 1.0),
1034+
(0.2, 0.0, 1.0),
1035+
(0.3, 0.0, 1.0),
1036+
(0.0, 1.0, 1.0),
1037+
(0.1, 1.0, 1.0),
1038+
(0.2, 1.0, 1.0),
1039+
(0.3, 1.0, 1.0),
1040+
]
1041+
)
1042+
curves.CreateCurveVertexCountsAttr([4, 4])
1043+
curves.GetPrim().AddAppliedSchema("PhysicsCurvesDeformableSimAPI")
1044+
curves.GetPrim().AddAppliedSchema("PhysicsCollisionAPI")
1045+
_bind_deformable_material(stage, curves.GetPrim(), "/World/CableMat", thickness=0.02)
1046+
curves.GetPrim().CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(False)
1047+
1048+
builder = newton.ModelBuilder()
1049+
builder.add_usd(stage)
1050+
1051+
# Two curves -> two separate articulations.
1052+
self.assertEqual(builder.articulation_count, 2)
1053+
b0, b1 = group_range(builder, "cable", "/World/Cable", "body")
1054+
self.assertEqual(b1 - b0, 6)
1055+
pairs = set(builder.shape_collision_filter_pairs)
1056+
# Within the first curve, the non-adjacent pair is filtered.
1057+
self.assertIn(self._shape_pair(builder, b0, b0 + 2), pairs)
1058+
# Across the two curves (separate articulations), nothing is filtered.
1059+
self.assertNotIn(self._shape_pair(builder, b0, b0 + 3), pairs)
1060+
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+
9461068
def test_neg_inf_junction_stiffness_does_not_weld(self):
9471069
"""-inf is the material sentinel, not the attachment one (+inf = hard): a
9481070
junction authoring -inf stiffness is nonconforming and must not weld the

0 commit comments

Comments
 (0)