A fast, middleware-independent coordinate transform library for Rust.
transforms is a pure Rust library for managing coordinate transformations between different reference frames. It is designed for robotics and computer vision applications where tracking spatial relationships between sensors, actuators, and world coordinates is essential.
Key characteristics:
- Middleware-independent: No ROS2, DDS, or any communication layer dependencies. Use it standalone or wrap it with your own pub-sub system. Checkout roslibrust_transforms if you are looking for a wrapped system.
no_stdcompatible: Works in embedded and resource-constrained environments.- Memory safe: Uses
#![forbid(unsafe_code)]throughout. - Inspired by tf2: Familiar concepts for robotics developers, but with a Rust-first API.
- Transform Interpolation: Smooth interpolation between transforms at different timestamps using spherical linear interpolation (SLERP) for rotations and linear interpolation for translations.
- Transform Chaining: Automatic computation of transforms between indirectly connected frames by traversing the frame tree.
- Static Transforms: Transforms with the static timestamp value are treated as static (
t=0by default). - Time-based Buffer Management: Automatic cleanup of old transforms (with
stdfeature) or manual cleanup (forno_std). - O(log n) Lookups: Efficient transform retrieval using
BTreeMapstorage. - Transformable Trait: Implement on your own types to make them transformable between coordinate frames.
- Transform Into: Resolve and apply transforms directly from a
Localizedvalue withget_transform_for, eliminating manual frame and timestamp bookkeeping.
get_transform, get_transform_for, and get_transform_at now take &self instead of &mut self, making concurrent reads possible without exclusive access.
// No &mut needed — share the registry freely
let registry: &Registry = /* ... */;
let tf = registry.get_transform("base", "sensor", timestamp)?;Resolve and apply a transform directly from any type that implements Localized, without manual frame/timestamp bookkeeping.
let point = Point { position: Vector3::new(1.0, 0.0, 0.0), orientation: Quaternion::identity(), timestamp, frame: "camera".into() };
let tf = registry.get_transform_for(&point, "map")?;All core types are now generic over time via the TimePoint trait. std::time::SystemTime works out of the box. A new get_transform_at API enables querying transforms at different timestamps per frame ("time travel").
// Use SystemTime instead of Timestamp
let mut registry = Registry::<SystemTime>::new(Duration::from_secs(60));
// Time travel: source at t1, target at t2, through a fixed frame
let tf = registry.get_transform_at("target", t2, "source", t1, "world")?;Fixed a bug where static transforms (timestamp = 0) and dynamic transforms could not coexist in the same tree. Buffer expiration now uses the latest inserted timestamp instead of wall-clock time.
// Static sensor mount + dynamic robot pose now work together
registry.add_transform(static_camera_mount); // timestamp = 0
registry.add_transform(dynamic_robot_pose); // timestamp = now
let tf = registry.get_transform("map", "camera", Timestamp::now())?;First stable release with no_std support, transform chaining, SLERP interpolation, Transformable trait, and automatic buffer cleanup.
let mut registry = Registry::new(Duration::from_secs(60));
registry.add_transform(transform);
let result = registry.get_transform("base", "sensor", timestamp)?;Add to your Cargo.toml:
[dependencies]
transforms = "1.4.1"| Feature | Default | Description |
|---|---|---|
std |
Yes | Enables automatic buffer cleanup and Timestamp::now() |
For no_std environments:
[dependencies]
transforms = { version = "1.4.1", default-features = false }use core::time::Duration;
use transforms::{
geometry::{Quaternion, Transform, Vector3},
time::Timestamp,
Registry,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a registry with 60-second transform buffer
let mut registry = Registry::new(Duration::from_secs(60));
let timestamp = Timestamp::now();
// Define a transform: sensor is 1 meter along X-axis from base
let transform = Transform {
translation: Vector3::new(1.0, 0.0, 0.0),
rotation: Quaternion::identity(),
timestamp,
parent: "base".into(),
child: "sensor".into(),
};
// Add and retrieve the transform
registry.add_transform(transform);
let result = registry.get_transform("base", "sensor", timestamp)?;
println!("Transform: {:?}", result);
Ok(())
}// std feature
pub fn new(max_age: Duration) -> Self
// no_std
pub fn new() -> Self
pub fn add_transform(&mut self, transform: Transform<T>)
pub fn get_transform(&mut self, from: &str, to: &str, timestamp: T) -> Result<Transform<T>, TransformError>
pub fn get_transform_for<U: Localized<T>>(&mut self, value: &U, target_frame: &str) -> Result<Transform<T>, TransformError>
pub fn delete_transforms_before(&mut self, timestamp: T)| Type | Description |
|---|---|
Transform<T = Timestamp> |
Rigid body transformation (translation + rotation + timestamp + frames) |
Vector3 |
3D vector with x, y, z components (f64) |
Quaternion |
Unit quaternion for rotations with w, x, y, z components (f64) |
Timestamp |
Time representation in nanoseconds (u128) |
TimePoint |
Trait for custom timestamp types used by Transform, Buffer, and Registry |
Point |
Example transformable type with position, orientation, timestamp, frame |
For complete API documentation, see docs.rs/transforms.
The library is organized around three core components:
┌─────────────────────────────────────────────────────────┐
│ Registry │
│ ┌─────────────────────────────────────────────────┐ │
│ │ HashMap<child_frame, Buffer> │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Buffer "b" │ │ Buffer "c" │ ... │ │
│ │ │ parent: "a" │ │ parent: "b" │ │ │
│ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │
│ │ │ │Transform│ │ │ │Transform│ │ │ │
│ │ │ │ @ t=0 │ │ │ │ @ t=1 │ │ │ │
│ │ │ │Transform│ │ │ │Transform│ │ │ │
│ │ │ │ @ t=1 │ │ │ │ @ t=2 │ │ │ │
│ │ │ └─────────┘ │ │ └─────────┘ │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
The main interface for managing transforms. It stores Buffer instances (one per child frame) and handles:
- Adding new transforms
- Retrieving transforms between any two frames (with automatic chaining)
- Traversing the frame tree to compute indirect transforms
- Automatic cleanup of expired transforms (with
stdfeature)
Time-indexed storage for transforms between a specific child-parent frame pair. Uses a BTreeMap<T, Transform<T>> for O(log n) lookups with automatic interpolation for timestamps between stored values.
The core data structure representing a rigid body transformation:
pub struct Transform<T = Timestamp>
where
T: TimePoint,
{
pub translation: Vector3, // Position offset (x, y, z)
pub rotation: Quaternion, // Orientation (w, x, y, z)
pub timestamp: T, // When this transform is valid
pub parent: String, // Destination frame
pub child: String, // Source frame
}Implement Transformable on your own types to make them transformable, and Localized to enable automatic transform lookup via get_transform_for:
pub trait Localized<T = Timestamp>
where
T: TimePoint,
{
fn frame(&self) -> &str;
fn timestamp(&self) -> T;
}
pub trait Transformable<T = Timestamp>
where
T: TimePoint,
{
fn transform(&mut self, transform: &Transform<T>) -> Result<(), TransformError>;
}The Localized trait provides frame and timestamp introspection, while Transformable handles applying transforms. They are separate so that pure geometry types can implement Transformable without needing frame/timestamp metadata. The library provides a Point type as a reference implementation of both traits.
Static transforms (timestamp = 0) are ideal for fixed relationships like sensor mounts:
// Static transform: camera mount position (never changes)
let camera_mount = Transform {
translation: Vector3::new(0.1, 0.0, 0.5),
rotation: Quaternion::identity(),
timestamp: Timestamp::zero(), // Static!
parent: "base".into(),
child: "camera".into(),
};
// Dynamic transform: robot position (changes over time)
let robot_position = Transform {
translation: Vector3::new(x, y, 0.0),
rotation: Quaternion::identity(),
timestamp: Timestamp::now(),
parent: "map".into(),
child: "base".into(),
};Query transforms between frames that aren't directly connected:
// Add transforms: map -> base -> arm -> gripper
registry.add_transform(map_to_base);
registry.add_transform(base_to_arm);
registry.add_transform(arm_to_gripper);
// Query: map -> gripper (automatically chains through base and arm)
let result = registry.get_transform("map", "gripper", timestamp)?;The library automatically traverses the frame tree and composes the necessary transforms.
When querying at a timestamp between two stored transforms, the library interpolates:
// Store transforms at t=0 and t=2
registry.add_transform(transform_at_t0);
registry.add_transform(transform_at_t2);
// Query at t=1: automatically interpolates between t=0 and t=2
let interpolated = registry.get_transform("a", "b", timestamp_at_t1)?;- Translation: Linear interpolation
- Rotation: Spherical linear interpolation (SLERP)
Transform points between coordinate frames using the Transformable trait:
use transforms::{
geometry::{Point, Quaternion, Transform, Vector3},
time::Timestamp,
Transformable,
};
// Create a point in the camera frame
let mut point = Point {
position: Vector3::new(1.0, 0.0, 0.0),
orientation: Quaternion::identity(),
timestamp: Timestamp::now(),
frame: "camera".into(),
};
// Get transform from camera to base
let transform = registry.get_transform("camera", "base", point.timestamp)?;
// Transform the point (mutates point.frame to "base")
point.transform(&transform)?;Use get_transform_for to resolve and apply a transform in one step, without manually specifying the source frame or timestamp:
// Create a point in the camera frame
let mut point = Point {
position: Vector3::new(1.0, 0.0, 0.0),
orientation: Quaternion::identity(),
timestamp: Timestamp::now(),
frame: "camera".into(),
};
// Resolve transform from the point's frame to map, then apply it
let transform = registry.get_transform_for(&point, "map")?;
point.transform(&transform)?;
// point.frame is now "map"If the point is already in the target frame, an identity transform is returned. This works with any type that implements Localized.
Compute the inverse of a transform:
let base_to_sensor = registry.get_transform("base", "sensor", timestamp)?;
let sensor_to_base = base_to_sensor.inverse()?;In no_std environments, you must manually manage buffer cleanup:
use transforms::{
geometry::{Quaternion, Transform, Vector3},
time::Timestamp,
Registry,
};
use core::time::Duration;
// Create registry (no max_age parameter in no_std)
let mut registry = Registry::new();
// Create timestamp manually (no Timestamp::now() in no_std)
let timestamp = (Timestamp::zero() + Duration::from_secs(100)).unwrap();
let transform = Transform {
translation: Vector3::new(1.0, 0.0, 0.0),
rotation: Quaternion::identity(),
timestamp,
parent: "a".into(),
child: "b".into(),
};
registry.add_transform(transform);
// Manual cleanup required in no_std
let cutoff = (Timestamp::zero() + Duration::from_secs(50)).unwrap();
registry.delete_transforms_before(cutoff);For multi-threaded applications, wrap the registry in appropriate synchronization primitives:
use std::sync::Arc;
use tokio::sync::Mutex;
let registry = Arc::new(Mutex::new(Registry::new(Duration::from_secs(60))));
// Writer task
let registry_writer = registry.clone();
tokio::spawn(async move {
let mut r = registry_writer.lock().await;
r.add_transform(transform);
});
// Reader task
let registry_reader = registry.clone();
tokio::spawn(async move {
let r = registry_reader.lock().await;
let result = r.get_transform("a", "b", timestamp);
});This library draws inspiration from ROS2's tf2 (Transform Framework 2), solving the same fundamental problem of coordinate frame tracking. Here's how they compare:
| Concept | Description |
|---|---|
| Frame Tree | Both maintain parent-child relationships between coordinate frames |
| Time Buffering | Both store transforms over time for historical lookups |
| Interpolation | Both interpolate between transforms for intermediate timestamps |
| Transform Chaining | Both compute transforms between non-adjacent frames automatically |
| Static Transforms | Both support transforms that don't change over time |
| Aspect | ROS2 tf2 | transforms |
|---|---|---|
| Distribution | Distributed across nodes via DDS | Single-process, local only |
| Middleware | Tightly coupled to ROS2/DDS | None - completely standalone |
| Language | C++ with Python/other bindings | Pure Rust |
no_std |
Not supported | Fully supported |
| Async Pattern | waitForTransform() with callbacks |
Synchronous (user manages async) |
| Error Handling | C++ exceptions | Rust Result types |
| Buffer Default | 10 seconds | User-configured |
| Cleanup | Automatic background process | Automatic (std) or manual (no_std) |
A core design principle of this library is middleware independence. Unlike tf2, which is deeply integrated with ROS2's DDS-based communication layer, this library has zero middleware dependencies. If you are looking for a crate which drop in integrates with ROS (roslibrust_transforms)[https://docs.rs/roslibrust_transforms/latest/roslibrust_transforms/] is an option.
This means:
- No ROS2 required: Use in any Rust application, not just ROS2 nodes
- No DDS overhead: No network traffic, serialization, or distributed consensus
- Embedded-friendly: Works in
no_stdenvironments with minimal footprint - Bring your own transport: If you need distributed transforms, wrap with your preferred pub-sub system (DDS, MQTT, ZeroMQ, custom protocol, etc.)
This design makes the library suitable for:
- Monolithic robotics applications
- Embedded systems and microcontrollers
- Simulations and testing without ROS2
- Applications with custom communication requirements
In plain terms:
TimePointis a trait (an interface). It says what a time type must do so transforms can be stored, compared, and interpolated.Timestampis the default struct (a concrete type). It stores time as nanoseconds in au128.
Use Timestamp if you want the default behavior.
Registry::new(...) is shorthand for Registry::<Timestamp>::new(...).
If you need a custom clock or custom time representation, implement TimePoint and use Registry::<CustomTimestamp>::new(...).
With std, std::time::SystemTime support is already implemented, so Registry::<SystemTime>::new(...) works out of the box.
- O(log n) lookups: Transforms are stored in
BTreeMapindexed by timestamp - Automatic cleanup: Prevents unbounded memory growth (with
stdfeature) - Minimal allocations: Efficient internal data structures
Benchmarks are available in the benches/ directory. Run with:
cargo benchThis library intentionally limits its scope to rigid body transformations (translation and rotation). The following are explicitly not supported:
- Scaling transformations
- Skew transformations
- Perspective transformations
- Non-rigid transformations
- Affine transformations beyond rigid body motion
- API parity with ROS2 tf2
- Non-linear interpolation
- Extrapolation
This focused scope keeps the library fast, predictable, and specialized for robotics applications. For more general transformation needs, consider a linear algebra or computer graphics library.
The examples/ directory contains complete working examples:
| Example | Description |
|---|---|
std_minimal.rs |
Basic async usage with Tokio |
std_full.rs |
Complete feature demonstration |
no_std_minimal.rs |
Minimal no_std usage |
no_std_full.rs |
Full no_std features with manual cleanup |
Run examples with:
cargo run --example std_full
cargo run --example no_std_minimal --no-default-featuresContributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
This project is licensed under the MIT License - see the LICENSE file for details.