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
11 changes: 11 additions & 0 deletions rerun_py/rerun_bindings/rerun_bindings.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,17 @@ class _UrdfJointInternal:
If `clamp` is False (default), values outside limits are used as-is without warnings.
"""

def compute_transform_columns(self, values: list[float], clamp: bool = False) -> dict[str, Any]:
"""
Compute transforms for this joint at multiple values in a single call.

Returns a dictionary with translations, quaternions, frame names, and warnings
for use with columnar APIs like `send_columns`.

If `clamp` is True, values outside joint limits will be clamped and a warning is generated.
If `clamp` is False (default), values outside limits are used as-is without warnings.
"""

class _UrdfLinkInternal:
"""Internal Rust representation of a URDF link."""

Expand Down
34 changes: 34 additions & 0 deletions rerun_py/rerun_sdk/rerun/urdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
from rerun_bindings import _UrdfJointInternal, _UrdfLinkInternal, _UrdfTreeInternal

if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path

from . import Transform3D
from ._baseclasses import ComponentColumnList

__all__ = ["UrdfJoint", "UrdfLink", "UrdfTree"]

Expand Down Expand Up @@ -107,6 +109,38 @@ def compute_transform(self, value: float, clamp: bool = True) -> Transform3D:
child_frame=result["child_frame"],
)

def compute_transform_columns(self, values: Sequence[float], clamp: bool = True) -> ComponentColumnList:
"""
Compute transforms for this joint at multiple values, returning columnar data for use with `send_columns`.

Parameters
----------
values:
Joint values: angles in radians (revolute/continuous) or distances in meters (prismatic).
Values outside limits are clamped with a warning if `clamp` is True.
clamp:
Whether to clamp & warn about values outside joint limits.

Returns
-------
ComponentColumnList
Columnar transform data ready for use with :func:`rerun.send_columns`.

"""
from . import Transform3D

result = self._inner.compute_transform_columns(list(values), clamp=clamp)

for warning in result["warnings"]:
warnings.warn(warning, UserWarning, stacklevel=2)

return Transform3D.columns(
translation=result["translations"],
quaternion=result["quaternions_xyzw"],
parent_frame=[result["parent_frame"]] * len(values),
child_frame=[result["child_frame"]] * len(values),
)

def __repr__(self) -> str:
return self._inner.__repr__()

Expand Down
60 changes: 60 additions & 0 deletions rerun_py/src/urdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,66 @@ impl PyUrdfJoint {
}
}

/// Compute transforms for this joint at multiple values in a single call.
///
/// Returns a dictionary with:
/// - `"translations"`: list of `(x, y, z)` tuples
/// - `"quaternions_xyzw"`: list of `(x, y, z, w)` tuples
/// - `"parent_frame"`: single string (constant per joint)
/// - `"child_frame"`: single string (constant per joint)
/// - `"warnings"`: list of warning strings
#[pyo3(signature = (values, clamp = false))]
#[allow(clippy::needless_pass_by_value)] // PyO3 requires owned Vec for Python list extraction
pub fn compute_transform_columns(
&self,
py: Python<'_>,
values: Vec<f64>,
clamp: bool,
) -> PyResult<PyObject> {
let mut translations = Vec::with_capacity(values.len());
let mut quaternions = Vec::with_capacity(values.len());
let mut warnings = Vec::new();
let mut parent_frame = String::new();
let mut child_frame = String::new();

for (i, &value) in values.iter().enumerate() {
match urdf_joint_transform::internal::compute_joint_transform(&self.0, value, clamp) {
Ok(result) => {
translations.push((
result.translation.x,
result.translation.y,
result.translation.z,
));
quaternions.push((
result.quaternion.x,
result.quaternion.y,
result.quaternion.z,
result.quaternion.w,
));
if let Some(warning) = result.warning {
warnings.push(warning);
}
if i == 0 {
parent_frame = result.parent_frame;
child_frame = result.child_frame;
}
}
Err(e @ urdf_joint_transform::Error::UnsupportedJointType(_)) => {
return Err(PyNotImplementedError::new_err(e.to_string()));
}
}
}

let dict = pyo3::types::PyDict::new(py);
dict.set_item("translations", translations)?;
dict.set_item("quaternions_xyzw", quaternions)?;
dict.set_item("parent_frame", parent_frame)?;
dict.set_item("child_frame", child_frame)?;
dict.set_item("warnings", warnings)?;

Ok(dict.into())
}

fn __repr__(&self) -> String {
format!(
"UrdfJoint(name={:?}, type={}, parent={:?}, child={:?})",
Expand Down
25 changes: 25 additions & 0 deletions rerun_py/tests/unit/test_urdf_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,31 @@ def test_urdf_tree_transform() -> None:
)


def test_urdf_compute_transform_columns() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)

joint = tree.get_joint_by_name("1")
assert joint is not None

values = [0.0, 0.5, 1.0]
columns = joint.compute_transform_columns(values, clamp=True)

from rerun._baseclasses import ComponentColumnList

assert isinstance(columns, ComponentColumnList)
assert len(columns) > 0

# Verify the result is usable with send_columns by checking that
# the individual transform matches what compute_transform returns.
single = joint.compute_transform(0.5, clamp=True)
assert single.translation is not None
assert single.quaternion is not None

# Verify that out-of-range values produce warnings.
with pytest.warns(UserWarning, match="outside limits"):
joint.compute_transform_columns([joint.limit_upper + 1.0], clamp=True)


def assert_quat_equivalent(actual: rr.components.RotationQuatBatch, expected: list[float]) -> None:
actual_values = actual.pa_array.to_pylist()[0]
dot = sum(a * b for a, b in zip(actual_values, expected, strict=False))
Expand Down
Loading