Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
- Fix masked `SolverCoupledProxy.reset()` calls clearing proxy feedback history for unselected worlds.
- Fix hydroelastic primitive texture SDF generation to sample analytic primitive distances instead of temporary tessellated meshes. (#3239)
- Fix MJCF, URDF, and USD imports rendering collision-only bodies as visuals when the asset authors visual geometry elsewhere. (#3291)
- 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.
- Fix `SchemaResolverPhysx` reading every D6 translational limit gain from the `linear` instance instead of its `transX`, `transY`, or `transZ` instance.
- Fix USD capsule, cylinder, and cone visuals and sites without authored `radius`/`height` to use the UsdGeom schema fallbacks, matching collision shapes.
- Fix `ViewerUSD` texture consumers observing partially written PNGs by publishing generated textures atomically (#3288)
Expand Down
13 changes: 11 additions & 2 deletions docs/concepts/usd_parsing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ Known gaps of the experimental importer, tracked as follow-ups:
(dynamics only, per the proposal). Cloth and volume deformables cannot disable particle
collision in Newton yet: they warn and import colliding. A welded cable graph shares one
shape configuration, so any collision-enabled member curve makes the whole graph collide
(mixed authoring warns).
(mixed authoring warns). Self-collision resolves with the opposite polarity: one member
curve authoring ``newton:selfCollisionEnabled = false`` disables self-collision for the
whole graph (conflicting authored values warn), and once any member authors a value the
``enable_self_collisions`` argument no longer applies to that graph.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* **Collision and graphics geometry** -- separate collision or render geometry under a
deformable body is not simulated or driven (embedding is not implemented): untagged
PointBased graphics geometry warns and is skipped (a static import would leave a frozen
Expand Down Expand Up @@ -217,6 +220,12 @@ A welded rod graph gets one articulation per connected component; each of its cu
own body range but shares that articulation. Attachment joints that tie a cable to other bodies
close a loop, so they stay outside the articulation.

Self-collision is resolved per articulation from ``newton:selfCollisionEnabled`` authored on
the curve prim itself (see the articulation remapping below). Disabling it filters collisions
between the articulation's non-adjacent segments; adjacent segments are already filtered by the
cable joints. Sibling curves of a multi-curve prim are separate articulations, so the filter
never spans them.

.. code-block:: python

result = builder.add_usd("cables.usda", return_deformable_results=True)
Expand Down Expand Up @@ -359,7 +368,7 @@ The table below shows PhysX attribute remapping examples:

**Newton articulation remapping:**

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

.. list-table:: Newton Articulation Remapping
:header-rows: 1
Expand Down
1 change: 1 addition & 0 deletions newton/_src/utils/import_usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4363,6 +4363,7 @@ def initialize_free_joint_velocities() -> None:
linear_unit=linear_unit,
ignore_paths=ignore_paths,
verbose=verbose,
enable_self_collisions=enable_self_collisions,
path_body_map=path_body_map,
path_shape_map=path_shape_map,
path_cable_map=path_cable_map,
Expand Down
60 changes: 60 additions & 0 deletions newton/_src/utils/import_usd_deformable_cable.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@

from __future__ import annotations

import itertools
import math
import warnings
from dataclasses import replace
from typing import TYPE_CHECKING

import warp as wp

from ..usd.schema_resolver import PrimType
from .import_usd_deformable_utils import (
_DEFAULT_CABLE_RADIUS,
_apply_cable_masses,
Expand All @@ -40,6 +43,22 @@
_warn_unsupported_rest_fields,
)

if TYPE_CHECKING:
from ..sim.builder import ModelBuilder


def _filter_cable_self_collisions(builder: ModelBuilder, bodies: list[int]) -> None:
"""Filter every shape pair among a cable articulation's bodies to disable self-collision.

Like the rigid importer's self-collision filtering, this emits every body pair, including
the adjacent ones the CABLE joints already filter (``collision_filter_parent``); the
duplicates are deduplicated when the filter pairs are consumed.
"""
for b1, b2 in itertools.combinations(bodies, 2):
for s1 in builder.body_shapes[b1]:
for s2 in builder.body_shapes[b2]:
builder.add_shape_collision_filter_pair(s1, s2)


def _read_validated_curve_topology(curves, path: str, *, warn: bool = True):
"""Read a cable prim's ``points`` / ``curveVertexCounts`` after validating the partition.
Expand Down Expand Up @@ -418,6 +437,34 @@ def global_node(local: tuple[str, int]) -> int:
body_frame_origin="com",
)

# Resolve self-collision only for a colliding graph; for the welded-graph policy see
# docs/concepts/usd_parsing.rst. get_value_with_resolver returns resolver=None for an
# unauthored curve (it stays neutral); the default= below does not feed the result, it
# only suppresses the unresolved-value diagnostic, so do not drop it as dead code.
if collision_enabled:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Could you collect all active resolver attributes for each accepted cable prim before this collision check? get_value_with_resolver() only records the resolver that supplies the winning value, and this whole block is skipped for a non-colliding welded graph. That means result["schema_attrs"] can omit authored attributes from the other active resolver, or omit the cable prim entirely. Would something like this work here, with the same collection added in the standalone cable path?

Suggested change
if collision_enabled:
for key in comp_paths:
if ctx.collect_schema_attrs:
ctx.resolver.collect_prim_attrs(curve_recs[key].prim)
if collision_enabled:

A regression test with both Newton and PhysX resolvers, plus a non-colliding welded graph, would help pin this down.

authored_self_collisions: list[bool] = []
for key in comp_paths:
value, resolver = ctx.resolver.get_value_with_resolver(
curve_recs[key].prim,
prim_type=PrimType.ARTICULATION,
key="self_collision_enabled",
default=ctx.enable_self_collisions,
verbose=verbose,
)
if resolver is not None:
authored_self_collisions.append(bool(value))
graph_self_collision = (
all(authored_self_collisions) if authored_self_collisions else ctx.enable_self_collisions
)
if len(set(authored_self_collisions)) > 1:
warnings.warn(
f"cable graph '{cid}': welded cables mix self-collision-enabled and "
f"self-collision-disabled curves; the whole graph disables self-collision.",
stacklevel=2,
)
if not graph_self_collision:
_filter_cable_self_collisions(builder, body_ids)

# Partition graph bodies back to their owning curve, and rebuild the per-prim anchor
# maps the curve-to-xform attachment pass reads (point index / segment index -> body).
per_prim_segments: dict[str, dict[int, tuple[int, float]]] = {}
Expand Down Expand Up @@ -627,6 +674,17 @@ def _deformable_import_cable(ctx: _DeformableImportContext, consumed_cable_curve
has_shape_collision=collision_enabled,
has_particle_collision=collision_enabled,
)
# Each curve in this prim becomes its own articulation via add_rod below, so resolve the
# prim's self-collision flag once and apply the filter per curve (not across sibling curves).
self_collision_enabled = bool(
ctx.resolver.get_value(
prim,
prim_type=PrimType.ARTICULATION,
key="self_collision_enabled",
default=ctx.enable_self_collisions,
verbose=verbose,
)
)

cable_bodies: list[int] = []
cable_joints: list[int] = []
Expand Down Expand Up @@ -719,6 +777,8 @@ def _deformable_import_cable(ctx: _DeformableImportContext, consumed_cable_curve
wrap_in_articulation=True,
body_frame_origin="com",
)
if collision_enabled and not self_collision_enabled:
_filter_cable_self_collisions(builder, bodies)
cable_bodies.extend(bodies)
cable_joints.extend(joints)
cable_point_runs.append((start, n, bodies))
Expand Down
2 changes: 2 additions & 0 deletions newton/_src/utils/import_usd_deformable_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,5 +1051,7 @@ class _DeformableImportContext:
path_soft_attrs: dict
path_attachment_map: dict
path_attachment_attrs: dict
# Default for self-collision when an articulation authors no newton:selfCollisionEnabled.
enable_self_collisions: bool
# Filled by _scout_deformable_prims so the passes iterate buckets instead of the stage.
prims: _DeformablePrimBuckets = field(default_factory=_DeformablePrimBuckets)
176 changes: 173 additions & 3 deletions newton/tests/test_import_usd_deformable_cable.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ class TestUSDDeformableCable(unittest.TestCase):
"""Curve-deformable (cable) parsing into rods of capsule bodies + cable joints."""

@staticmethod
def _author_attached_cable_pair(*, gap, stiffness=None, damping=None):
def _author_attached_cable_pair(*, gap, stiffness=None, damping=None, collision=True):
"""Two 4-point cables separated by ``gap`` in y with a point->point attachment
(P0 of B onto P0 of A); returns the stage."""
stage = _deformable_stage()
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)]
pts_b = [(0.0, gap, 1.0), (0.1, gap, 1.0), (0.2, gap, 1.0), (0.3, gap, 1.0)]
_add_cable_curve(stage, "/World/CableA", pts_a)
_add_cable_curve(stage, "/World/CableB", pts_b)
_add_cable_curve(stage, "/World/CableA", pts_a, collision=collision)
_add_cable_curve(stage, "/World/CableB", pts_b, collision=collision)
_add_physics_attachment(
stage,
"/World/Junction",
Expand All @@ -53,6 +53,13 @@ def _author_attached_cable_pair(*, gap, stiffness=None, damping=None):
)
return stage

@staticmethod
def _shape_pair(builder, body_a, body_b):
"""Canonical (min, max) shape-filter pair for the first shapes of two bodies."""
s1 = builder.body_shapes[body_a][0]
s2 = builder.body_shapes[body_b][0]
return (min(s1, s2), max(s1, s2))

def test_attachment_weld_policy_rejects_compliant_or_apart(self):
"""A curve-to-curve attachment welds only when hard AND coincident: a compliant
(zero or finite stiffness) or spatially-apart junction leaves two independent
Expand Down Expand Up @@ -943,6 +950,169 @@ def test_cable_collision_gating(self):
self.assertEqual(is_colliding, expected_colliding, f"shape {i}")
builder.finalize()

def test_cable_self_collision_disabled_filters_non_adjacent_pairs(self):
"""newton:selfCollisionEnabled=False filters non-adjacent cable segment shape pairs.

Adjacent segments are already filtered by the CABLE joints; the flag extends the
filtering to non-adjacent pairs, mirroring the general rigid importer. An authored value
beats the ``enable_self_collisions`` argument, which only applies when the cable authors
nothing, and a non-colliding cable emits no filter pairs at all.
"""
from pxr import Sdf

# 5 points -> 4 segment bodies, so there are non-adjacent pairs to check.
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)]
for case, authored, default, collision, expect_filtered in (
("authored_disabled", False, True, True, True),
("unauthored", None, True, True, False),
("default_disabled", None, False, True, True),
("authored_beats_default", True, False, True, False),
("non_colliding", False, True, False, False),
):
with self.subTest(case=case):
stage = _deformable_stage()
curve = _add_cable_curve(stage, "/World/Cable", pts, collision=collision)
if authored is not None:
curve.GetPrim().CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(
authored
)
builder = newton.ModelBuilder()
builder.add_usd(stage, enable_self_collisions=default)
b0, b1 = group_range(builder, "cable", "/World/Cable", "body")
self.assertEqual(b1 - b0, 4)
# A non-adjacent segment pair (bodies b0 and b0+2).
pair = self._shape_pair(builder, b0, b0 + 2)
pairs = set(builder.shape_collision_filter_pairs)
if expect_filtered:
self.assertIn(pair, pairs)
else:
self.assertNotIn(pair, pairs)
if not collision:
# Non-colliding shapes never reach the broad phase, so the filter is skipped
# entirely (a colliding cable always emits the adjacent joint pairs).
self.assertEqual(pairs, set())

def test_cable_self_collision_physx_fallback_needs_resolver(self):
"""physxArticulation:enabledSelfCollisions gates a cable only with the PhysX resolver.

The default resolver list is Newton-only, so the PhysX articulation attribute is
silently ignored unless the caller opts in via ``schema_resolvers``.
"""
from pxr import Sdf

# 5 points -> 4 segment bodies, so there is a non-adjacent pair to check.
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)]

def non_adjacent_pair_filtered(**add_usd_kwargs):
stage = _deformable_stage()
curve = _add_cable_curve(stage, "/World/Cable", pts)
curve.GetPrim().CreateAttribute("physxArticulation:enabledSelfCollisions", Sdf.ValueTypeNames.Bool).Set(
False
)
builder = newton.ModelBuilder()
builder.add_usd(stage, **add_usd_kwargs)
b0, _ = group_range(builder, "cable", "/World/Cable", "body")
return self._shape_pair(builder, b0, b0 + 2) in set(builder.shape_collision_filter_pairs)

self.assertTrue(non_adjacent_pair_filtered(schema_resolvers=[SchemaResolverPhysx()]))
self.assertFalse(non_adjacent_pair_filtered())

def test_welded_graph_self_collision_disabled_filters_non_adjacent_pairs(self):
"""A welded cable graph honors newton:selfCollisionEnabled=False on any member curve.

Disabling self-collision on one welded member filters non-adjacent segment shape pairs
across the whole graph articulation (both within a curve and between welded curves), and
the importer warns only when colliding members author disagreeing values. Only authored
values count, so an unauthored sibling never lets the ``enable_self_collisions`` default
override them, and a non-colliding graph emits no filter pairs and no warning at all.
"""
from pxr import Sdf

for case, author_a, author_b, default, collision, expect_filtered in (
("disabled", False, None, True, True, True),
("all_disabled", False, False, True, True, True),
("mixed", False, True, True, True, True),
("default", None, None, True, True, False),
("authored_beats_default", True, None, False, True, False),
("non_colliding_mixed", False, True, True, False, False),
):
with self.subTest(case=case):
# Hard, coincident junction welds CableA and CableB into one graph articulation.
stage = self._author_attached_cable_pair(gap=0.0, collision=collision)
for path, authored in (("/World/CableA", author_a), ("/World/CableB", author_b)):
if authored is not None:
stage.GetPrimAtPath(path).CreateAttribute(
"newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool
).Set(authored)
builder = newton.ModelBuilder()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
builder.add_usd(stage, enable_self_collisions=default)
# Only genuinely disagreeing authored values warn.
warned = any("disables self-collision" in str(w.message) for w in caught)
self.assertEqual(warned, case == "mixed")
self.assertEqual(builder.articulation_count, 1)
a0, _ = group_range(builder, "cable", "/World/CableA", "body")
b0, _ = group_range(builder, "cable", "/World/CableB", "body")
pairs = set(builder.shape_collision_filter_pairs)
# A within-curve non-adjacent pair and a cross-curve non-adjacent pair.
within = self._shape_pair(builder, a0, a0 + 2)
cross = self._shape_pair(builder, a0 + 2, b0 + 2)
if expect_filtered:
self.assertIn(within, pairs)
self.assertIn(cross, pairs)
else:
self.assertNotIn(within, pairs)
self.assertNotIn(cross, pairs)
if not collision:
# Non-colliding shapes never reach the broad phase, so the filter is skipped
# entirely (a colliding graph always emits the adjacent joint pairs).
self.assertEqual(pairs, set())

def test_self_collision_disabled_does_not_filter_across_curves_in_one_prim(self):
"""A multi-curve BasisCurves prim filters each curve's own segments, not across curves.

Every curveVertexCounts entry becomes its own articulation via add_rod, so disabling
self-collision must filter within a curve while leaving independent sibling curves in
the same prim free to collide.
"""
from pxr import Sdf, UsdGeom

stage = _deformable_stage()
curves = UsdGeom.BasisCurves.Define(stage, "/World/Cable")
curves.CreateTypeAttr().Set(UsdGeom.Tokens.linear)
# Two independent 4-point curves (3 segment bodies each) in one prim.
curves.CreatePointsAttr(
[
(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.0, 1.0, 1.0),
(0.1, 1.0, 1.0),
(0.2, 1.0, 1.0),
(0.3, 1.0, 1.0),
]
)
curves.CreateCurveVertexCountsAttr([4, 4])
curves.GetPrim().AddAppliedSchema("PhysicsCurvesDeformableSimAPI")
curves.GetPrim().AddAppliedSchema("PhysicsCollisionAPI")
_bind_deformable_material(stage, curves.GetPrim(), "/World/CableMat", thickness=0.02)
curves.GetPrim().CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(False)

builder = newton.ModelBuilder()
builder.add_usd(stage)

# Two curves -> two separate articulations.
self.assertEqual(builder.articulation_count, 2)
b0, b1 = group_range(builder, "cable", "/World/Cable", "body")
self.assertEqual(b1 - b0, 6)
pairs = set(builder.shape_collision_filter_pairs)
# Within the first curve, the non-adjacent pair is filtered.
self.assertIn(self._shape_pair(builder, b0, b0 + 2), pairs)
# Across the two curves (separate articulations), nothing is filtered.
self.assertNotIn(self._shape_pair(builder, b0, b0 + 3), pairs)

def test_neg_inf_junction_stiffness_does_not_weld(self):
"""-inf is the material sentinel, not the attachment one (+inf = hard): a
junction authoring -inf stiffness is nonconforming and must not weld the
Expand Down
Loading