Transforms
A fast, middleware-independent coordinate transform library for Rust.
Overview
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: builds and runs on bare-metal targets, with a heap allocator. All arithmetic isf64, which is software-emulated on the single-precision FPUs most Cortex-M boards carry — see the supported envelope for the rates and tree depths that fit an MCU.- Memory safe: Uses
#![forbid(unsafe_code)]throughout. - Inspired by tf2: Familiar concepts for robotics developers, but with a Rust-first API.
Features
- 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 carrying
Stamp::Staticare valid for all time; build them withTransform::static_between. No timestamp value is reserved — every instant, includingt=0on boot-relative clocks, is ordinary dynamic data. - Time-based Buffer Management:
Registry::with_max_agecleans up old transforms automatically;Registry::newkeeps them until manual cleanup. Both work with and withoutstd. - O(log n) Lookups: Efficient transform retrieval using
BTreeMapstorage — O(log n) in stored samples per frame, linear in chain depth for indirect frames. - 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. - Latest Common Time:
latest_common_timereports the newest instant a chain can serve — freshness is a first-class answer, not a retry loop or an assumed publisher rate.
What's New
Full version history lives in CHANGELOG.md.
v2.1.0
Registry::latest_common_time: "what is the newest instant this chain can serve?" is now a first-class query — exact also for mid-tree pairs,Stamp::Staticfor all-static chains, and a diagnosedNoCommonTimeerror when no instant is servable at all. It replaces the retry-off-coveredidiom theNotFoundAtdocs used to describe, andexamples/std_full.rsnow reads at the servable instant instead of a hardcoded lag.
v2.0.0 highlights
- Correct by construction: a
Transformis validated where it is built —Transform::newandTransform::static_betweenreturnResultand reject non-finite values and non-unit rotations, deserialization runs the same check, and the private fields keep a built transform valid. The frame tree is strict (single pinned parent, no cycles), and lookups either answer the exact question asked or return an error — the silent-wrong-answer failure modes of 1.x are gone. - Tested on deployment architectures: CI executes the full test suite natively on x86_64 and ARM64 (Raspberry Pi, NVIDIA Jetson).
- Real
no_std: builds for bare-metal targets — CI proves it onthumbv7em-none-eabihf(STM32 F4/F7/H7 flight controllers),thumbv6m-none-eabi(RP2040),thumbv8m.main-none-eabihf(Cortex-M33), andriscv32imc-unknown-none-elf(ESP32-C3) — thestdfeature is additive, and automatic cleanup (with_max_age) works in both modes. - One flat error per call: every
Registrymethod reportsRegistryError<T>— insertion and lookup alike — with the lookup payloads typed in your own time type instead of pre-formatted seconds. Diagnosing a failed lookup is a singlematch, not three nested ones. - Rust-first API cleanup: exact
==with tolerant comparison in theapproxtraits,#[non_exhaustive]errors, private internals, optionalserdesupport, an enforced panic policy, and MSRV 1.86. - A stated envelope:
f64is a commitment — f32 and mixed precision are Non-Goals — and the Performance section publishes what that costs: measured per-operation timings and allocation counts, ~320 B of resident heap per stored sample under short frame names, and the rates and tree depths that do and do not fit an MCU.
add_transform is now fallible — the headline migration for 1.x users:
registry.add_transform?;
The full list of breaking changes with before/after code lives in MIGRATION.md.
Installation
Add to your Cargo.toml:
[]
= "2.1.0"
Feature Flags
| Feature | Default | Description |
|---|---|---|
std |
Yes | Enables Timestamp::now(), its panic-free Timestamp::try_now(), and the SystemTime time type |
serde |
No | Serialize/Deserialize for the geometry and time types |
Minimum supported Rust version: 1.86 (checked in CI).
Note on serde: Timestamp is #[serde(transparent)], so it serializes as
the bare u64 nanosecond count — an integer every serde format encodes
natively, and serde_json, postcard, bincode (1.x and 2.x), and
MessagePack via rmp-serde all round-trip the full range, with a
foreign-language consumer reading a plain number. Stamp is an explicitly
tagged enum — {"At": 1753142400000000000} and "Static" in JSON — so
staticness is spelled out rather than implied by an absent value: a
timestamp field that is missing or null is a decode error, never an
eternal static transform. Struct field order and Stamp's variant order
are part of the wire contract for non-self-describing formats.
Deserializing a Transform runs the constructors' validation, so a
denormalized rotation or a non-finite component is a deserialization error
rather than a transform that answers lookups with plausible nonsense.
Note on approx: the AbsDiffEq/RelativeEq impls on the geometry types
make approx 0.5 part of this crate's public API — a deliberate
commitment, since tolerant comparison is the documented alternative to the
exact ==.
For no_std environments (requires a heap allocator):
[]
= { = "2.1.0", = false }
Quick Start
use Duration;
use ;
API Reference
Registry
// No automatic cleanup (also available via Default)
latest_common_time answers "what is the newest instant get_transform
can serve for this pair?" — the oldest of the chain's dynamic hops' newest
samples, exact also for mid-tree pairs, Stamp::Static when every hop is
static (the caller picks the instant). The intended idiom is that call
followed by get_transform at the returned instant, both under the same
lock guard when the registry is shared.
Every registry call reports errors::RegistryError<T>, one flat
#[non_exhaustive] enum: NonUnitRotation, NonFiniteValues,
SelfReferentialFrame, ReparentingNotSupported, CycleDetected and
StaticDynamicConflict from insertion; UnknownFrame, Disconnected and
NotFoundAt from lookups; NoCommonTime from latest_common_time, which
shares UnknownFrame and Disconnected with the lookups so the same
match arms diagnose both. One match reaches every cause and every
payload — NotFoundAt carries the frame the walk stopped at, the
requested: T timestamp, and covered: Option<(T, T)>: Some(range) is a
gap in data the frame holds (a timing question), None is a frame holding
nothing at all (waiting will not help). NoCommonTime reuses the
covered shape with its own meaning: Some(range) names a hop whose
range starts after the newest instant the rest of the chain serves —
disjoint coverage, not a timing question — and None a hop holding
nothing. The timestamps stay in your own
time type, so they compare directly against the clock you asked with. The
one wrapping variant, RegistryError::TransformError, reports a geometry
or time failure of an operation on the resolved chain; it never carries
NonUnitRotation or NonFiniteValues, which have exactly one spelling.
Core Types
| Type | Description |
|---|---|
Transform<T = Timestamp> |
Rigid body transformation (translation + rotation + timestamp + frames), validated at construction |
Vector3 |
3D vector with x, y, z components (f64) |
Quaternion |
Quaternion for rotations (expected unit norm) with w, x, y, z components (f64) |
Timestamp |
Time representation in nanoseconds (u64, ~584 years of range) |
Stamp<T = Timestamp> |
When a transform is valid: At(T) for one instant, Static for all time |
TimePoint |
Trait for custom timestamp types used by Transform and Registry |
Point |
Example transformable type with position, orientation, timestamp, frame (public fields, built with Point::new) |
For complete API documentation, see docs.rs/transforms.
Architecture
Registry is the entire public entry point; the buffers below it are
crate-private storage, shown here because they explain the lookup costs:
┌─────────────────────────────────────────────────────────┐
│ Registry │
│ ┌─────────────────────────────────────────────────┐ │
│ │ HashMap<child_frame, Buffer> │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Buffer "b" │ │ Buffer "c" │ ... │ │
│ │ │ parent: "a" │ │ parent: "b" │ │ │
│ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │
│ │ │ │Transform│ │ │ │Transform│ │ │ │
│ │ │ │ @ t=1 │ │ │ │ @ t=1 │ │ │ │
│ │ │ │Transform│ │ │ │Transform│ │ │ │
│ │ │ │ @ t=2 │ │ │ │ @ t=2 │ │ │ │
│ │ │ └─────────┘ │ │ └─────────┘ │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Registry
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
Registry::with_max_age)
Buffer (internal)
Time-indexed storage for transforms between a specific child-parent frame pair, owned by the registry and not reachable from outside the crate. A dynamic buffer uses a BTreeMap<T, Transform<T>> for O(log n) lookups with automatic interpolation for timestamps between stored values; a static buffer stores its single transform inline and serves it for any requested time.
Transform
The core data structure representing a rigid body transformation:
// Fields are private: construction validates, and no field can be poked back out of it.
new and static_between reject non-finite components and rotations whose
norm deviates from 1.0 by more than geometry::UNIT_NORM_TOLERANCE. Values
derived from validated transforms — inverse, interpolate, *
composition, and registry lookups — are not re-checked, because rotation norms
drift by a few ulps per composition and rejecting that would fail legitimate
long chains; validate is there for a transform whose provenance you do not
control.
Localized and Transformable Traits
Implement Transformable on your own types to make them transformable, and Localized to enable automatic transform lookup via get_transform_for:
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, and the Transformable docs state the exact map an implementation owes: rotate, then translate, with the transform's rotation on the left of the orientation composition.
Usage Examples
Static vs Dynamic Transforms
Static transforms (built with Transform::static_between, carrying
Stamp::Static) are ideal for fixed relationships like sensor mounts.
A given child frame is either static or dynamic: mixing the two kinds for the same
child frame is rejected by add_transform with a StaticDynamicConflict error.
The frame tree is strict: a child frame's parent is pinned by its first
transform (re-parenting is rejected — remove the frame with
Registry::remove_frame and re-add it to change its parent), a frame cannot
be its own parent, and cycles are rejected at insertion. Removing a
mid-tree frame strands its descendants (they keep their pin to the removed
parent), so re-parent a subtree by removing and re-adding each descendant.
Re-publishing a transform at an already-stored timestamp replaces that
sample: last write wins. Native re-parenting support may become a feature
in a later release.
// Static transform: camera mount position (never changes)
let camera_mount: Transform = static_between?;
// Dynamic transform: robot position (changes over time)
let robot_position = new?;
Transform Chaining
Query transforms between frames that aren't directly connected:
// Add transforms: map -> base -> arm -> gripper
registry.add_transform?;
registry.add_transform?;
registry.add_transform?;
// Query: map -> gripper (automatically chains through base and arm)
let result = registry.get_transform?;
The library automatically traverses the frame tree and composes the necessary transforms.
Transform Interpolation
When querying at a timestamp between two stored transforms, the library interpolates:
// Store transforms at t=0 and t=2
registry.add_transform?;
registry.add_transform?;
// Query at t=1: automatically interpolates between t=0 and t=2
let interpolated = registry.get_transform?;
- Translation: Linear interpolation
- Rotation: Spherical linear interpolation (SLERP)
Interpolation spans any gap between two stored samples, however large —
bounding data freshness is the caller's job, via max_age and insert
cadence. There is no extrapolation beyond the stored range.
Point Transformation
Transform points between coordinate frames using the Transformable trait:
use ;
// Create a point in the camera frame
let mut point = new;
// Get the transform that maps camera-frame coordinates into the base frame
let transform = registry.get_transform?;
// Transform the point (mutates point.frame to "base")
point.transform?;
Transform Into Target Frame
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 = new;
// Resolve transform from the point's frame to map, then apply it
let transform = registry.get_transform_for?;
point.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.
Inverse Transforms
Compute the inverse of a transform:
let base_to_sensor = registry.get_transform?;
let sensor_to_base = base_to_sensor.inverse?;
no_std Usage
The same API is available in no_std environments, including automatic
cleanup via Registry::with_max_age; only a registry built with
Registry::new requires manual cleanup:
use ;
use Duration;
// Registry::new() has no automatic cleanup; Registry::with_max_age works in
// no_std too if you prefer automatic expiry
let mut registry = new;
// Create timestamp manually (no Timestamp::now() in no_std)
let timestamp = .unwrap;
let transform = new
.unwrap;
registry.add_transform.unwrap;
// Manual cleanup for registries built without with_max_age
let cutoff = .unwrap;
registry.remove_transforms_before;
Concurrent Access
Every lookup takes &self and the registry has no interior mutability, so
concurrent readers need no exclusive access: wrap it in an RwLock and only
the publisher blocks.
use Arc;
use RwLock;
let registry = new;
// Writer task - exclusive access
let registry_writer = registry.clone;
spawn;
// Reader task - shared access, does not block other readers. Both calls
// under one guard: ask which instant the chain serves, then look it up.
let registry_reader = registry.clone;
spawn;
examples/std_full.rs is this pattern as a program that compiles and runs
(cargo run --example std_full), including the freshest-pose idiom: ask
latest_common_time which instant the chain serves and get_transform at
exactly that instant, both under the same read guard so no writer can
advance or evict coverage between the two calls.
Comparison with ROS2 tf2
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:
Similarities
| 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 |
Key Differences
| 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 — latest_common_time answers readiness; waiting is the caller's |
| Error Handling | C++ exceptions | Rust Result types |
| Buffer Default | 10 seconds | User-configured |
| Cleanup | Automatic background process | Automatic (with_max_age) or manual (Registry::new), both modes |
Middleware Independence
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 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-capable: runs in
no_stdwith a heap allocator; how much tree and how much rate fit is set byf64math and per-sample memory, both quantified in Supported envelope - 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, at the rates and depths the supported envelope covers
- Simulations and testing without ROS2
- Applications with custom communication requirements
TimePoint vs Timestamp
In plain terms:
TimePointis a trait (an interface). It says what a time type must do so transforms can be stored, compared, and interpolated: beCopy + Ord + Debugand provideduration_since,checked_sub, andas_seconds_lossy.Timestampis the default struct (a concrete type). It stores time as nanoseconds in au64, which covers about 584 years from the clock's epoch — mid-2554 for a Unix-epoch clock.
Use Timestamp if you want the default behavior.
Registry defaults its type parameter to Timestamp: in type position,
let registry: Registry = Registry::new() is Registry<Timestamp>. In
expression position the type is inferred from usage, so annotate if the
surrounding code doesn't pin it down.
If you need a custom clock or custom time representation, implement TimePoint and use Registry::<CustomTimestamp>.
With std, std::time::SystemTime support is already implemented, so Registry::<SystemTime> works out of the box.
Performance
- O(log n) time lookups: transforms are stored in
BTreeMapindexed by timestamp; multi-hop lookups scale linearly with chain depth, and a failed lookup runs an O(frames) diagnosis scan to name the cause - Early-exit chain resolution: walks stop as soon as the target frame is reached
- At most one inversion per lookup: each half of the chain is composed in
its natural direction, so a lookup toward an ancestor
(
get_transform("map", "lidar", t)) inverts nothing at all — a single-hop lookup at a stored timestamp returns that stored transform bit for bit - Automatic cleanup:
with_max_ageregistries prevent unbounded memory growth; eviction pops expired entries from the front of the map, O(log n + evicted) per insert - Allocation profile: a single-hop lookup performs 5 heap allocations
toward an ancestor and 6 in the reverse direction (~0.5 KB churn),
regardless of buffer size, plus ~2 per additional hop (135 at 64 hops) —
frame names are
Strings; insertion into an existing frame does not clone the frame name - All arithmetic is
f64: on single-precision-FPU cores (Cortex-M4F, M33) transform math runs through soft-float; only double-precision FPUs (M7-class) execute it in hardware - Identical numbers in both feature modes:
sqrt,sin, andacoscome from libm with and withoutstd, never from the platform's own math library, so a desktop replay reproduces the target's interpolated rotations bit for bit
Measured cost
On x86-64 (Intel i7-1065G7, release + LTO, counting global allocator), against frames holding 1000 dynamic samples each:
| Operation | Time | Allocations |
|---|---|---|
add_transform, steady state under with_max_age |
~0.4 µs | 2 |
get_transform, 1 hop, at a stored stamp |
~0.6 µs | 5 |
get_transform, 1 hop, interpolated |
~0.7 µs | 5 |
get_transform, 4 hops toward an ancestor, interpolated |
~1.9 µs | 11 |
get_transform rejecting an unknown frame among 1000 frames |
~9 µs | 3 |
Resident memory is about 320 B per stored sample while both frame names
are 32 characters or shorter — a 120-byte Transform, its entry in the
ordered map, and the two frame-name strings, including allocator block
granularity. Every sample owns its own copy of both names, so the figure
rises with them: each name adds another 32 B per sample for every further
32 characters. A ROS-style pair of 45-character namespaced names therefore
costs ~64 B more, about 385 B per sample, and a dynamic edge published
at 1 kHz under a one-second max_age holds ~320 KB under short names but
~385 KB under that pair. At equal name length 32-bit targets are smaller
(Transform is 96 B there), but the name strings are not — so size an MCU
heap from the names you actually publish, not from the headline figure.
Supported envelope
The crate commits to f64 (see Non-Goals), so on cores
without a double-precision FPU every coordinate operation is emulated in
software. That, together with the per-sample memory above, is what decides
fitness:
| Platform | Workload | Memory for a 1 s window | Basis |
|---|---|---|---|
| x86-64 / ARM64 SBC (Raspberry Pi, Jetson) | 1 kHz tick: 6 dynamic edges published and 3 lookups of 3–5 hops, ~11 µs/tick ≈ 1% of one core | ~1.9 MB, against gigabytes | measured |
Cortex-M7 (STM32 F7/H7 — hardware f64) |
between the rows above and below: the one named MCU class that does not pay soft-float | same per-sample figure | neither measured nor estimated |
Cortex-M4F / M33 (f64 in software) |
~100 Hz, mostly-static tree, one or two dynamic edges: single-digit percent of the core | ~64 KB of a 192 KB SRAM | estimated |
| Cortex-M4F / M33 | 1 kHz over 6 dynamic edges: does not fit — RAM runs out before CPU does | ~1.9 MB against 192 KB SRAM | estimated |
| Cortex-M0+ / RV32IMC (no FPU) | static trees and occasional lookups; one four-hop lookup is estimated above 1 ms | ~32 KB per dynamic edge at 100 Hz | estimated |
The estimated rows come from first principles — the soft-float symbols a bare-metal build links, scaled by the x86-64 measurements above — and nothing here was executed on target, so treat them as ±2×. The memory column is arithmetic on the short-name per-sample figure above, so it bounds the 32-bit rows only for frame names that short — namespaced names push every row up.
Static transforms cost one sample forever, so publishing fixed mounts with
Transform::static_between is the cheapest way to keep an embedded tree
inside this envelope; with_max_age bounds the rest.
Benchmarks are available in the benches/ directory. Run with:
Non-Goals
This 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
- f32 or mixed-precision arithmetic (every coordinate and rotation is f64)
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.
Examples
The examples/ directory contains complete working examples:
| Example | Description |
|---|---|
std_minimal.rs |
Registry basics: transform a point between frames, with interpolation |
std_full.rs |
Concurrent async usage with Tokio: readers query at latest_common_time under one read guard |
std_advanced.rs |
Time travel between frames with get_transform_at |
no_std_minimal.rs |
Minimal no_std usage: add and retrieve a transform |
no_std_full.rs |
Point transform and interpolation with manual cleanup |
no_std_advanced.rs |
Time travel in no_std with manual cleanup |
Run examples with:
AI-Assisted Development
Parts of this library have been developed with AI assistance (Claude Code), including some work that predates v2.0.0. Starting with v2.0.0, AI-assisted contributions follow a formal framework:
-
Every AI-assisted commit is disclosed with an
Assisted-by:commit trailer (following the Linux kernel convention, e.g.Assisted-by: Claude:claude-fable-5), making AI involvement machine-queryable from v2.0.0 onward: -
The standards, invariants, and conventions that AI agents must follow when working on this repository are documented in AGENTS.md.
-
Every AI-assisted change is reviewed, tested, and understood by the maintainer before merging.
I, the owner of this repo, take full responsibility for every line in this repository, however it was produced.
Contributing
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
This applies to contributors as well as the maintainer: AI-assisted
contributions must follow the standards in AGENTS.md and carry the
Assisted-by: commit trailer described above.
License
This project is licensed under the MIT License - see the LICENSE file for details.