Skip to content

Commit db91242

Browse files
Fix TetMesh._infer_frequency for length-1 attribute arrays (#3240)
Co-authored-by: Miguel Angel Zamora Mora <mzamoramora@nvidia.com>
1 parent 3463b80 commit db91242

7 files changed

Lines changed: 350 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
- Fix `ModelBuilder.add_usd()` silently ignoring authored Newton USD collision schemas when `newton-usd-schemas` is unavailable. USD import now fails clearly instead of continuing without the registered schema defaults required for SDF and hydroelastic configuration.
146146
- Fix `cable_cross_slide_table` example stability so the cable-driven table reliably tracks its rectangular path and catches drift during regression runs.
147147
- Fix URDF `package://` mesh fallback resolution without `resolve-robotics-uri-py` so package names only match full path components instead of unrelated directory-name substrings
148+
- Fix TetMesh USD loading to infer unambiguous length-1 custom attributes as `AttributeFrequency.ONCE`, flatten indexed primvars, and omit uninferable attributes without dropping the soft body. `ModelBuilder.add_usd()` imports only registered TetMesh attributes using their declared frequencies. (#3228)
148149
- Fix `ModelBuilder.collapse_fixed_joints()` crashing with `IndexError` when a `mujoco:equality_constraint` row omits optional fields (`anchor`, `relpose`) that carry defaults. (#3054)
149150
- Fix `ViewerGL.set_model()` resetting headless/interactive camera and wind state when switching between models that use the same up-axis. (#2658)
150151
- Fix bend force calculation error in Style3D solver

newton/_src/geometry/types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,11 +1718,15 @@ def _infer_frequency(
17181718
first_dim = arr.shape[0] if arr.ndim >= 1 else 1
17191719
counts = {"vertex_count": vertex_count, "tet_count": tet_count, "tri_count": tri_count}
17201720
matches = [label for label, c in counts.items() if first_dim == c and c > 0]
1721+
if first_dim == 1:
1722+
matches.append("ONCE")
17211723
if len(matches) > 1:
17221724
raise ValueError(
17231725
f"Cannot infer frequency for custom attribute '{name}': array length {first_dim} matches "
17241726
f"{', '.join(matches)}. Pass an explicit (array, frequency) tuple instead."
17251727
)
1728+
if "ONCE" in matches:
1729+
return Model.AttributeFrequency.ONCE
17261730
if first_dim == vertex_count and vertex_count > 0:
17271731
return Model.AttributeFrequency.PARTICLE
17281732
if first_dim == tet_count and tet_count > 0:
@@ -1851,6 +1855,11 @@ def create_from_usd(prim, *, compat_namespaces: Sequence[str] | None = None) ->
18511855
``k_lambda``) and density on the returned TetMesh. Material properties
18521856
are set to ``None`` if not present.
18531857
1858+
Custom primvars use their resolved interpolation to determine attribute
1859+
frequency. Other custom arrays use length-based inference; arrays whose
1860+
frequency is ambiguous or cannot be inferred emit a warning and are
1861+
omitted without preventing the TetMesh from loading.
1862+
18541863
Material-attribute namespaces (deprecated default): with ``compat_namespaces=None``
18551864
(the default) the legacy vendor namespaces (``omniphysics:`` / ``physxDeformableBody:``)
18561865
are read off any bound material, matching the pre-canonical behavior. That default is

newton/_src/usd/utils.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,31 @@ def get_custom_attribute_values(
647647
return out
648648

649649

650+
def _get_tetmesh_custom_attribute_values(
651+
prim: Usd.Prim,
652+
custom_attributes: Sequence[ModelBuilder.CustomAttribute],
653+
) -> dict[str, np.ndarray]:
654+
"""Read builder-declared TetMesh arrays without inferring their frequency."""
655+
out: dict[str, np.ndarray] = {}
656+
for spec in custom_attributes:
657+
usd_name = spec.usd_attribute_name
658+
if not usd_name or usd_name == "*":
659+
continue
660+
usd_attr = prim.GetAttribute(usd_name)
661+
if not usd_attr or not usd_attr.HasAuthoredValue():
662+
continue
663+
primvar = UsdGeom.Primvar(usd_attr)
664+
value = primvar.ComputeFlattened() if primvar else usd_attr.Get()
665+
if value is None:
666+
continue
667+
if spec.usd_value_transformer is not None:
668+
value = spec.usd_value_transformer(value, {"prim": prim, "attr": spec})
669+
if value is None:
670+
continue
671+
out[spec.key] = np.asarray(value)
672+
return out
673+
674+
650675
def _newell_normal(P: np.ndarray) -> np.ndarray:
651676
"""Newell's method for polygon normal (not normalized)."""
652677
x = y = z = 0.0
@@ -1559,7 +1584,12 @@ def _material_authors_unscoped_canonical_attrs(prim: Usd.Prim) -> bool:
15591584
)
15601585

15611586

1562-
def get_tetmesh(prim: Usd.Prim, *, compat_namespaces: Sequence[str] | None = None) -> TetMesh:
1587+
def get_tetmesh(
1588+
prim: Usd.Prim,
1589+
*,
1590+
compat_namespaces: Sequence[str] | None = None,
1591+
_load_custom_attributes: bool = True,
1592+
) -> TetMesh:
15631593
"""Load a tetrahedral mesh from a USD prim with the ``UsdGeom.TetMesh`` schema.
15641594
15651595
Reads vertex positions from the ``points`` attribute and tetrahedral
@@ -1571,6 +1601,11 @@ def get_tetmesh(prim: Usd.Prim, *, compat_namespaces: Sequence[str] | None = Non
15711601
``k_lambda``) and density on the returned TetMesh. Material properties
15721602
are set to ``None`` if not present.
15731603
1604+
Custom primvars use their resolved interpolation to determine attribute
1605+
frequency. Other custom arrays use length-based inference; arrays whose
1606+
frequency is ambiguous or cannot be inferred emit a warning and are
1607+
omitted without preventing the TetMesh from loading.
1608+
15741609
Material-attribute namespaces (deprecated default): with ``compat_namespaces=None``
15751610
(the default) the legacy vendor namespaces (``omniphysics:`` / ``physxDeformableBody:``)
15761611
are read off any bound material, matching the pre-canonical behavior. That default is
@@ -1716,6 +1751,15 @@ def get_tetmesh(prim: Usd.Prim, *, compat_namespaces: Sequence[str] | None = Non
17161751
# density too; a plain rigid-style physics material is a valid source.
17171752
density = _get_physics_material_density(material_prim)
17181753

1754+
if not _load_custom_attributes:
1755+
return TetMesh(
1756+
vertices=vertices,
1757+
tet_indices=tet_indices,
1758+
k_mu=k_mu,
1759+
k_lambda=k_lambda,
1760+
density=density,
1761+
)
1762+
17191763
# Read custom primvars and attributes (per-vertex, per-tet, etc.)
17201764
# Primvar interpolation is used to determine the attribute frequency
17211765
# when available, falling back to length-based inference in TetMesh.__init__.
@@ -1738,7 +1782,7 @@ def get_tetmesh(prim: Usd.Prim, *, compat_namespaces: Sequence[str] | None = Non
17381782
name = primvar.GetPrimvarName()
17391783
if name in ("st", "normals"):
17401784
continue # skip well-known primvars handled elsewhere
1741-
val = primvar.Get()
1785+
val = primvar.ComputeFlattened()
17421786
if val is not None:
17431787
arr = np.array(val)
17441788
interp = primvar.GetInterpolation()
@@ -1773,14 +1817,32 @@ def get_tetmesh(prim: Usd.Prim, *, compat_namespaces: Sequence[str] | None = Non
17731817
except (TypeError, ValueError):
17741818
pass # skip non-array attributes
17751819

1776-
return TetMesh(
1820+
result = TetMesh(
17771821
vertices=vertices,
17781822
tet_indices=tet_indices,
17791823
k_mu=k_mu,
17801824
k_lambda=k_lambda,
17811825
density=density,
1782-
custom_attributes=custom_attributes if custom_attributes else None,
17831826
)
1827+
tri_count = len(result.surface_tri_indices) // 3
1828+
for name, value in custom_attributes.items():
1829+
if name in result._RESERVED_ATTR_KEYS:
1830+
warnings.warn(
1831+
f"{prim.GetPath()}: custom attribute '{name}' uses a reserved TetMesh name; skipping the attribute.",
1832+
stacklevel=2,
1833+
)
1834+
continue
1835+
if isinstance(value, tuple):
1836+
arr, frequency = value
1837+
else:
1838+
arr = value
1839+
try:
1840+
frequency = result._infer_frequency(arr, result.vertex_count, result.tet_count, tri_count, name)
1841+
except ValueError as exc:
1842+
warnings.warn(f"{prim.GetPath()}: {exc}; skipping the attribute.", stacklevel=2)
1843+
continue
1844+
result.custom_attributes[name] = (np.asarray(arr), frequency)
1845+
return result
17841846

17851847

17861848
def _find_physics_material_prim(prim: Usd.Prim):

newton/_src/utils/import_usd.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,11 @@ def _get_tetmesh_cached(prim: Usd.Prim) -> TetMesh:
10131013
stacklevel=2,
10141014
)
10151015
compat_ns = usd.DEFORMABLE_LEGACY_NAMESPACES
1016-
tetmesh_cache[prim_path] = usd.get_tetmesh(prim, compat_namespaces=compat_ns)
1016+
tetmesh_cache[prim_path] = usd.get_tetmesh(
1017+
prim,
1018+
compat_namespaces=compat_ns,
1019+
_load_custom_attributes=False,
1020+
)
10171021
return tetmesh_cache[prim_path]
10181022

10191023
def _has_visual_material_properties(material_props: dict[str, Any]) -> bool:

newton/_src/utils/import_usd_deformable_volume.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import numpy as np
1717
import warp as wp
1818

19+
from ..sim.model import Model
1920
from .import_usd_deformable_utils import (
2021
_apply_particle_masses,
2122
_bake_world_points,
@@ -104,16 +105,57 @@ def _deformable_import_volume(ctx: _DeformableImportContext) -> None:
104105
# whole import; skip the prim like other broken deformable geometry.
105106
warnings.warn(f"{path}: invalid TetMesh; skipping soft-body import ({exc}).", stacklevel=2)
106107
continue
108+
supported_frequencies = {
109+
Model.AttributeFrequency.PARTICLE,
110+
Model.AttributeFrequency.TETRAHEDRON,
111+
Model.AttributeFrequency.TRIANGLE,
112+
}
113+
registered_attributes = [
114+
attr
115+
for attr in builder.custom_attributes.values()
116+
if attr.frequency in supported_frequencies and attr.usd_attribute_name != "*"
117+
]
118+
registered_values = usd._get_tetmesh_custom_attribute_values(prim, registered_attributes)
119+
once_attributes = [
120+
attr
121+
for attr in builder.custom_attributes.values()
122+
if attr.frequency == Model.AttributeFrequency.ONCE and attr.usd_attribute_name != "*"
123+
]
124+
once_values = usd._get_tetmesh_custom_attribute_values(prim, once_attributes)
125+
for attr in once_attributes:
126+
if attr.key in once_values:
127+
warnings.warn(
128+
f"{path}: registered TetMesh attribute '{attr.usd_attribute_name}' has ONCE frequency, "
129+
"which cannot be attached per soft body and is not imported.",
130+
stacklevel=2,
131+
)
132+
frequency_counts = {
133+
Model.AttributeFrequency.PARTICLE: (tetmesh.vertex_count, "particle"),
134+
Model.AttributeFrequency.TETRAHEDRON: (tetmesh.tet_count, "tetrahedron"),
135+
Model.AttributeFrequency.TRIANGLE: (len(tetmesh.surface_tri_indices) // 3, "triangle"),
136+
}
137+
imported_attributes = {}
138+
for attr in registered_attributes:
139+
if attr.key not in registered_values:
140+
continue
141+
arr = np.asarray(registered_values[attr.key])
142+
actual_count = arr.shape[0] if arr.ndim >= 1 else 1
143+
expected_count, frequency_name = frequency_counts[attr.frequency]
144+
if arr.ndim == 0 or actual_count != expected_count:
145+
warnings.warn(
146+
f"{path}: registered TetMesh attribute '{attr.usd_attribute_name}' has array length "
147+
f"{actual_count}, which does not match {frequency_name} count {expected_count}; "
148+
"skipping the attribute.",
149+
stacklevel=2,
150+
)
151+
continue
152+
imported_attributes[attr.key] = (arr, attr.frequency)
107153
tetmesh_for_builder = tetmesh
108-
if tetmesh.custom_attributes:
109-
filtered_custom_attributes = {
110-
k: v for k, v in tetmesh.custom_attributes.items() if k in builder.custom_attributes
111-
}
112-
if len(filtered_custom_attributes) != len(tetmesh.custom_attributes):
113-
# Preserve the cached TetMesh while keeping add_usd's
114-
# current behavior of dropping unregistered import attrs.
115-
tetmesh_for_builder = copy.copy(tetmesh)
116-
tetmesh_for_builder.custom_attributes = filtered_custom_attributes
154+
if tetmesh.custom_attributes or imported_attributes:
155+
# The builder declaration supplies both the USD name and authoritative frequency.
156+
# Preserve the cached TetMesh because another import pass may reuse it.
157+
tetmesh_for_builder = copy.copy(tetmesh)
158+
tetmesh_for_builder.custom_attributes = imported_attributes
117159

118160
soft_mesh_mat = get_prim_world_mat(prim, None, incoming_world_xform)
119161
# Bake the full world affine into the tet vertices and pass an identity placement, so a

newton/tests/test_import_usd.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11605,13 +11605,14 @@ def test_tetmesh_custom_attributes_constructor(self):
1160511605
temperature = np.array([100.0, 200.0, 300.0, 400.0], dtype=np.float32)
1160611606
region_id = np.array([7], dtype=np.int32)
1160711607

11608-
# Single tet: vertex_count == tri_count == 4, so temperature needs explicit frequency
11608+
# Single tet: vertex_count == tri_count == 4, so temperature needs explicit frequency.
11609+
# regionId also needs explicit frequency because tet_count == 1 is ambiguous with ONCE.
1160911610
tm = newton.TetMesh(
1161011611
vertices,
1161111612
tet_indices,
1161211613
custom_attributes={
1161311614
"temperature": (temperature, newton.Model.AttributeFrequency.PARTICLE),
11614-
"regionId": region_id,
11615+
"regionId": (region_id, newton.Model.AttributeFrequency.TETRAHEDRON),
1161511616
},
1161611617
)
1161711618

@@ -11624,6 +11625,37 @@ def test_tetmesh_custom_attributes_constructor(self):
1162411625
assert_np_equal(arr, region_id)
1162511626
self.assertEqual(freq, newton.Model.AttributeFrequency.TETRAHEDRON)
1162611627

11628+
def test_tetmesh_custom_attributes_infer_once(self):
11629+
"""Test that length-1 arrays are inferred as ONCE when unambiguous."""
11630+
vertices = np.array(
11631+
[[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [0.5, 0.5, 0.5]],
11632+
dtype=np.float32,
11633+
)
11634+
tet_indices = np.array([0, 1, 2, 3, 0, 1, 2, 4], dtype=np.int32)
11635+
constant = np.array([42.0], dtype=np.float32)
11636+
11637+
tm = newton.TetMesh(
11638+
vertices,
11639+
tet_indices,
11640+
custom_attributes={"constant": constant},
11641+
)
11642+
11643+
arr, freq = tm.custom_attributes["constant"]
11644+
assert_np_equal(arr, constant)
11645+
self.assertEqual(freq, newton.Model.AttributeFrequency.ONCE)
11646+
11647+
def test_tetmesh_custom_attributes_ambiguous_once(self):
11648+
"""Test that length-1 arrays raise when tet_count is also 1."""
11649+
vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float32)
11650+
tet_indices = np.array([0, 1, 2, 3], dtype=np.int32)
11651+
11652+
with self.assertRaisesRegex(ValueError, "ONCE"):
11653+
newton.TetMesh(
11654+
vertices,
11655+
tet_indices,
11656+
custom_attributes={"ambig": np.array([1.0], dtype=np.float32)},
11657+
)
11658+
1162711659
def test_tetmesh_custom_attributes_empty_by_default(self):
1162811660
"""Test TetMesh has empty custom_attributes when none are provided."""
1162911661
vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float32)
@@ -11669,13 +11701,14 @@ def test_tetmesh_custom_attributes_npz_roundtrip(self):
1166911701
temperature = np.array([10.0, 20.0, 30.0, 40.0], dtype=np.float32)
1167011702
region_id = np.array([3], dtype=np.int32)
1167111703

11672-
# Single tet: vertex_count == tri_count == 4, so temperature needs explicit frequency
11704+
# Single tet: vertex_count == tri_count == 4, so temperature needs explicit frequency.
11705+
# regionId also needs explicit frequency because tet_count == 1 is ambiguous with ONCE.
1167311706
tm = newton.TetMesh(
1167411707
vertices,
1167511708
tet_indices,
1167611709
custom_attributes={
1167711710
"temperature": (temperature, newton.Model.AttributeFrequency.PARTICLE),
11678-
"regionId": region_id,
11711+
"regionId": (region_id, newton.Model.AttributeFrequency.TETRAHEDRON),
1167911712
},
1168011713
)
1168111714

0 commit comments

Comments
 (0)