Skip to content

Commit 917c165

Browse files
committed
Support attached cable endpoints in USD
Detect the attachment before rigid articulations are finalized so its target stays in the active contiguous joint range. Build rods outward from either attached endpoint to preserve parent-before-child joint order. Cover standalone bodies and floating- and fixed-base robots with unrelated bodies imported afterward.
1 parent 4e49d55 commit 917c165

4 files changed

Lines changed: 219 additions & 39 deletions

File tree

newton/_src/sim/builder.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8650,12 +8650,15 @@ def _add_rod_graph(
86508650
junction_collision_filter: bool = True,
86518651
color: Vec3 | None = None,
86528652
body_frame_origin: Literal["start", "com"] | None = None,
8653+
articulation_root_node: int | None = None,
86538654
articulation_root_joint_factory: Callable[[int, Transform], int] | None = None,
86548655
) -> tuple[list[int], list[int]]:
86558656
"""Build a rod graph with an optional importer-defined articulation root joint.
86568657

86578658
The callback runs after the root body exists and before the rod joints are created. Public
8658-
callers use :meth:`add_rod_graph`, which creates a free joint to the world instead.
8659+
callers use :meth:`add_rod_graph`, which creates a free joint to the world instead. An
8660+
importer may select the graph node where traversal starts so the resulting joints follow
8661+
parent-before-child order from an attached endpoint.
86598662
"""
86608663
if cfg is None:
86618664
cfg = self.default_shape_cfg
@@ -8682,6 +8685,13 @@ def _add_rod_graph(
86828685

86838686
num_nodes = len(node_positions)
86848687
num_edges = len(edges)
8688+
if articulation_root_node is not None:
8689+
if articulation_root_joint_factory is None:
8690+
raise ValueError("add_rod_graph: articulation_root_node requires an articulation root joint factory")
8691+
if articulation_root_node < 0 or articulation_root_node >= num_nodes:
8692+
raise ValueError(
8693+
f"add_rod_graph: articulation_root_node must be in [0, {num_nodes}), got {articulation_root_node}"
8694+
)
86858695
if quaternions is not None and len(quaternions) != num_edges:
86868696
raise ValueError(
86878697
f"add_rod_graph: quaternions must have {num_edges} elements for {num_edges} edges, "
@@ -8856,7 +8866,17 @@ def _build_joints_forest() -> list[int]:
88568866
visited = [False] * num_edges
88578867
component_index = 0
88588868

8859-
for start_edge in range(num_edges):
8869+
start_edges = list(range(num_edges))
8870+
if articulation_root_node is not None:
8871+
root_edges = node_incidence[articulation_root_node]
8872+
if not root_edges:
8873+
raise ValueError(
8874+
f"add_rod_graph: articulation_root_node {articulation_root_node} has no incident edge"
8875+
)
8876+
start_edges.remove(root_edges[0])
8877+
start_edges.insert(0, root_edges[0])
8878+
8879+
for start_edge in start_edges:
88608880
if visited[start_edge]:
88618881
continue
88628882

@@ -8871,9 +8891,14 @@ def _build_joints_forest() -> list[int]:
88718891
)
88728892
root_joint = self.add_joint_free(child=edge_bodies[start_edge], label=root_label)
88738893
else:
8894+
root_node = (
8895+
articulation_root_node
8896+
if component_index == 0 and articulation_root_node is not None
8897+
else edge_u[start_edge]
8898+
)
88748899
root_joint = articulation_root_joint_factory(
88758900
edge_bodies[start_edge],
8876-
_edge_anchor_xform(start_edge, edge_u[start_edge]),
8901+
_edge_anchor_xform(start_edge, root_node),
88778902
)
88788903
component_joints: list[int] = [root_joint]
88798904
component_edges: list[int] = []

newton/_src/utils/import_usd.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@
6161
_deformable_import_element_collision_filters,
6262
_deformable_remap_collapsed,
6363
)
64-
from .import_usd_deformable_cable import _deformable_import_cable, _deformable_prepare_cable_topology
64+
from .import_usd_deformable_cable import (
65+
_deformable_import_cable,
66+
_deformable_prepare_cable_topology,
67+
_read_cable_attachment_endpoint,
68+
)
6569
from .import_usd_deformable_cloth import _deformable_import_cloth
6670
from .import_usd_deformable_utils import (
6771
_LOADABLE_VISUAL_TYPE_NAMES_LOWER,
@@ -2824,6 +2828,45 @@ def _resolve_contact_attr(key, _prim=prim):
28242828
# This allows us to parse orphan joints (joints not included in any articulation)
28252829
# even when articulations are present in the USD.
28262830
processed_joints: set[str] = set()
2831+
2832+
cable_attachment_target_body_paths: set[str] = set()
2833+
if _deformable_prims.cables and _deformable_prims.attachments:
2834+
cable_topology: dict[str, tuple[int, bool]] = {}
2835+
for cable_prim in _deformable_prims.cables:
2836+
curves = UsdGeom.BasisCurves(cable_prim)
2837+
vertex_counts = curves.GetCurveVertexCountsAttr().Get() or []
2838+
if len(vertex_counts) != 1:
2839+
continue
2840+
cable_topology[str(cable_prim.GetPath())] = (
2841+
int(vertex_counts[0]),
2842+
curves.GetWrapAttr().Get() == UsdGeom.Tokens.periodic,
2843+
)
2844+
2845+
attachments_by_cable: dict[str, list[Usd.Prim]] = {}
2846+
for attachment_prim in _deformable_prims.attachments:
2847+
cable_path = _get_first_target(attachment_prim, "physics:src0")
2848+
if cable_path in cable_topology:
2849+
attachments_by_cable.setdefault(cable_path, []).append(attachment_prim)
2850+
2851+
for cable_path, attachment_prims in attachments_by_cable.items():
2852+
if len(attachment_prims) != 1:
2853+
continue
2854+
attachment_prim = attachment_prims[0]
2855+
point_count, closed = cable_topology[cable_path]
2856+
if _read_cable_attachment_endpoint(attachment_prim, deformable_read, point_count, closed) is None:
2857+
continue
2858+
target_path = _get_first_target(attachment_prim, "physics:src1")
2859+
target_prim = stage.GetPrimAtPath(target_path)
2860+
if not target_prim or not target_prim.IsValid():
2861+
continue
2862+
current_prim = target_prim
2863+
while current_prim and current_prim.IsValid():
2864+
current_path = str(current_prim.GetPath())
2865+
if current_path in body_specs:
2866+
cable_attachment_target_body_paths.add(current_path)
2867+
break
2868+
current_prim = current_prim.GetParent()
2869+
28272870
authored_articulation_root_paths = [
28282871
str(prim.GetPath())
28292872
for prim in Usd.PrimRange(stage.GetPrimAtPath(root_path), Usd.TraverseInstanceProxies())
@@ -2837,10 +2880,20 @@ def _resolve_contact_attr(key, _prim=prim):
28372880
if UsdPhysics.ObjectType.Articulation in ret_dict:
28382881
paths, articulation_descs = ret_dict[UsdPhysics.ObjectType.Articulation]
28392882

2883+
articulation_entries = list(zip(paths, articulation_descs, strict=False))
2884+
target_articulations = [
2885+
index
2886+
for index, (_path, desc) in enumerate(articulation_entries)
2887+
if cable_attachment_target_body_paths.intersection(str(body) for body in desc.articulatedBodies)
2888+
]
2889+
if len(target_articulations) == 1:
2890+
target_articulation = articulation_entries.pop(target_articulations[0])
2891+
articulation_entries.append(target_articulation)
2892+
28402893
articulation_id = builder.articulation_count
28412894
parent_prim = None
28422895
body_data = {}
2843-
for path, desc in zip(paths, articulation_descs, strict=False):
2896+
for path, desc in articulation_entries:
28442897
if warn_invalid_desc(path, desc):
28452898
continue
28462899
articulation_path = str(path)
@@ -4492,11 +4545,26 @@ def _aggregate_recorded_mass_properties(body_path: str, body_density: float | No
44924545
else:
44934546
bodies_to_articulate = new_bodies
44944547

4495-
if bodies_to_articulate:
4548+
cable_attachment_target_bodies = [
4549+
path_body_map[path] for path in cable_attachment_target_body_paths if path in path_body_map
4550+
]
4551+
if len(cable_attachment_target_bodies) == 1:
4552+
cable_attachment_target_body = cable_attachment_target_bodies[0]
4553+
bodies_before_cables = (
4554+
[cable_attachment_target_body] if cable_attachment_target_body in bodies_to_articulate else []
4555+
)
4556+
bodies_after_cables = [body_id for body_id in bodies_to_articulate if body_id != cable_attachment_target_body]
4557+
else:
4558+
bodies_before_cables = bodies_to_articulate
4559+
bodies_after_cables = []
4560+
4561+
def add_base_articulations(body_ids: list[int]) -> None:
4562+
if not body_ids:
4563+
return
44964564
if parent_body != -1:
44974565
# When parent_body is specified, manually add joints to floating bodies with correct parent
44984566
joint_children = set(builder.joint_child)
4499-
for body_id in bodies_to_articulate:
4567+
for body_id in body_ids:
45004568
if body_id in joint_children:
45014569
continue # Already has a joint
45024570
if builder.body_mass[body_id] <= 0:
@@ -4519,7 +4587,7 @@ def _aggregate_recorded_mass_properties(body_path: str, body_density: float | No
45194587
)
45204588
else:
45214589
joint_children = set(builder.joint_child)
4522-
for body_id in bodies_to_articulate:
4590+
for body_id in body_ids:
45234591
if body_id in joint_children:
45244592
continue
45254593
if builder.body_mass[body_id] <= 0:
@@ -4544,6 +4612,10 @@ def _aggregate_recorded_mass_properties(body_path: str, body_density: float | No
45444612
else:
45454613
builder.add_articulation([joint_id], label=body_path)
45464614

4615+
# Articulation joints occupy a contiguous range. Create the attached body's base joint
4616+
# immediately before the cable joints, then add unrelated base joints after the cable.
4617+
add_base_articulations(bodies_before_cables)
4618+
45474619
def initialize_free_joint_velocities() -> None:
45484620
imported_bodies = set(path_body_map.values())
45494621
for joint_id, joint_type in enumerate(builder.joint_type):
@@ -4576,8 +4648,8 @@ def initialize_free_joint_velocities() -> None:
45764648
qd_start = builder.joint_qd_start[joint_id]
45774649
builder.joint_qd[qd_start : qd_start + 6] = [*linear_velocity, *angular_velocity]
45784650

4579-
# Build deformables after rigid bodies, collider-mass computation, and the floating-body
4580-
# base-joint pass so physical attachment targets and their root joints already exist.
4651+
# Build deformables after rigid bodies and collider-mass computation so physical attachment
4652+
# targets already exist.
45814653
# Volume deformables (TetMesh -> soft body). PhysicsVolumeDeformableSimAPI (or a
45824654
# PhysicsDeformableBodyAPI) opts into the mass precedence; a bare TetMesh stays legacy.
45834655
# Mass precedence (proposal): per-point physics:masses > body mass > body density
@@ -4656,6 +4728,8 @@ def initialize_free_joint_velocities() -> None:
46564728
if _filter_prim and _filter_prim.IsValid():
46574729
_collect_filtered_pairs(_filter_prim)
46584730

4731+
add_base_articulations(bodies_after_cables)
4732+
46594733
def _resolve_collision_shape_ids(path: str) -> tuple[list[int], str | None]:
46604734
"""Resolve a filtered-pair endpoint to Newton shape indices, or an unsupported reason.
46614735

newton/_src/utils/import_usd_deformable_cable.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
@dataclass(frozen=True, slots=True)
7575
class _CableArticulationRoot:
7676
attachment_path: str
77+
cable_point: int
7778
parent_body: int
7879
parent_anchor: wp.vec3
7980

@@ -97,6 +98,34 @@ def _add_cable_articulation_root_joint(
9798
return joint
9899

99100

101+
def _read_cable_attachment_endpoint(prim, deformable_read, point_count: int, closed: bool) -> int | None:
102+
"""Return the attached endpoint when a hard attachment can root an open cable articulation."""
103+
if closed:
104+
return None
105+
if str(deformable_read(prim, "type0") or "") != "point":
106+
return None
107+
if str(deformable_read(prim, "type1") or "") != "xform":
108+
return None
109+
point_indices = [int(index) for index in (deformable_read(prim, "indices0") or [])]
110+
if len(point_indices) != 1 or point_indices[0] not in (0, point_count - 1):
111+
return None
112+
if deformable_read(prim, "indices1"):
113+
return None
114+
if len(_attachment_vec3_list(deformable_read(prim, "coords1"))) > 1:
115+
return None
116+
117+
enabled = deformable_read(prim, "attachmentEnabled")
118+
if enabled is not None and not bool(enabled):
119+
return None
120+
stiffness = deformable_read(prim, "stiffness")
121+
if stiffness is not None and float(stiffness) != math.inf:
122+
return None
123+
damping = deformable_read(prim, "damping")
124+
if damping is not None and (not math.isfinite(float(damping)) or float(damping) < 0.0):
125+
return None
126+
return point_indices[0]
127+
128+
100129
# Thickness attributes in resolution order: the current revision first, then the deprecated name.
101130
_CABLE_THICKNESS_ATTRS = ("curvesThickness", "thickness")
102131
_NEWTON_CURVE_DAMPING_ATTRS = (
@@ -393,34 +422,15 @@ def _read_cable_articulation_root(
393422
) -> _CableArticulationRoot | None:
394423
"""Return an attachment that can connect the cable articulation to its parent.
395424
396-
The supported case is a hard attachment from the cable's first endpoint to the world or a
425+
The supported case is a hard attachment from a cable endpoint to the world or a
397426
transform. If the transform belongs to a rigid body, that body must be in the most recently
398427
added articulation because articulation joints occupy contiguous ranges.
399428
"""
400429
deformable_read = ctx.deformable_read
401-
if cable.closed:
402-
return None
403-
if str(deformable_read(prim, "type0") or "") != "point":
404-
return None
405-
if str(deformable_read(prim, "type1") or "") != "xform":
406-
return None
407-
if [int(index) for index in (deformable_read(prim, "indices0") or [])] != [0]:
408-
return None
409-
if deformable_read(prim, "indices1"):
430+
cable_point = _read_cable_attachment_endpoint(prim, deformable_read, len(cable.positions), cable.closed)
431+
if cable_point is None:
410432
return None
411433
target_points = _attachment_vec3_list(deformable_read(prim, "coords1"))
412-
if len(target_points) > 1:
413-
return None
414-
415-
enabled = deformable_read(prim, "attachmentEnabled")
416-
if enabled is not None and not bool(enabled):
417-
return None
418-
stiffness = deformable_read(prim, "stiffness")
419-
if stiffness is not None and float(stiffness) != math.inf:
420-
return None
421-
damping = deformable_read(prim, "damping")
422-
if damping is not None and (not math.isfinite(float(damping)) or float(damping) < 0.0):
423-
return None
424434

425435
target_point = target_points[0] if target_points else wp.vec3(0.0, 0.0, 0.0)
426436
target = _resolve_attachment_target(ctx, target_path, target_point)
@@ -429,7 +439,7 @@ def _read_cable_articulation_root(
429439
parent_body, parent_anchor = target
430440
if parent_body >= 0 and parent_body not in bodies_in_latest_articulation:
431441
return None
432-
return _CableArticulationRoot(str(prim.GetPath()), parent_body, parent_anchor)
442+
return _CableArticulationRoot(str(prim.GetPath()), cable_point, parent_body, parent_anchor)
433443

434444

435445
def _deformable_prepare_cable_topology(
@@ -438,7 +448,7 @@ def _deformable_prepare_cable_topology(
438448
"""Prepare cable connectivity that must be known before creating cable joints.
439449
440450
Hard attachments between coincident cable points become shared nodes in one rod graph. For an
441-
open cable with one hard attachment at its first endpoint, that attachment connects the cable
451+
open cable with one hard attachment at an endpoint, that attachment connects the cable
442452
articulation to the world or a rigid body. Other attachments remain separate constraints.
443453
444454
Returns the cable paths built as shared rod graphs, the attachment paths represented by those
@@ -859,7 +869,7 @@ def _deformable_import_cable(
859869
"""Import single-curve cable deformables (linear ``GeomBasisCurves`` -> rod via ``add_rod``).
860870
861871
Curves already built as a rod graph are skipped. Each remaining cable is placed in an
862-
articulation. A physical attachment at the first endpoint provides the root joint when
872+
articulation. A physical attachment at either endpoint provides the root joint when
863873
possible; otherwise the cable receives a free joint to the world.
864874
"""
865875
from pxr import UsdGeom
@@ -882,7 +892,11 @@ def _deformable_import_cable(
882892

883893
if not (root_prim and root_prim.IsValid()):
884894
return
885-
for prim in ctx.prims.cables:
895+
cable_prims = sorted(
896+
ctx.prims.cables,
897+
key=lambda prim: str(prim.GetPath()) not in cable_articulation_roots,
898+
)
899+
for prim in cable_prims:
886900
path = str(prim.GetPath())
887901
if path in cables_in_shared_graphs:
888902
continue # already built as part of a welded rod graph
@@ -1069,6 +1083,7 @@ def _deformable_import_cable(
10691083
label=label,
10701084
wrap_in_articulation=True,
10711085
body_frame_origin="com",
1086+
articulation_root_node=articulation_root.cable_point,
10721087
articulation_root_joint_factory=partial(
10731088
_add_cable_articulation_root_joint,
10741089
builder,

0 commit comments

Comments
 (0)