tf_tree
A transform tree engine: store time-stamped rigid-body transforms between named coordinate frames and answer "where was frame A relative to frame B at time t?" — fast enough to sit inside a control loop, with diagnostics good enough to debug at 3 a.m.
This is the crate to depend on. It is the std facade: it re-exports the
no_std engine (tf_tree_core) and
adds the allocating conveniences that do not belong in it — the builder, the
plan-cached lookup, and Described, the Display layer that turns a Copy
error id into prose by consulting the arena.
It is not tf2, not a fork of it, and not affiliated with ROS. It is an
independent engine that solves the same problem with a different data structure.
There is no drop-in tf2_ros::Buffer shim and building one is not scheduled.
Install
That is the portable engine, and it is what the example below uses. Everything
that maps memory — a shared arena, the frozen .tft reader, tf_tree::open()'s
zero-config rendezvous — is behind the default-off shm feature and is Linux
only:
Python users want pip install transform_tree and import tf_tree: the
distribution name differs from the module because PyPI refuses tf_tree as too
close to the existing tftree. What cargo install tf_tree does — and does not
— install is in Version below, with the measurement; it is not repeated here.
In full
use ;
// Topology is declared up front: `build()` sizes one flat arena from exactly
// these edges, and nothing allocates after it returns.
let tree = new
.static_edge
.dynamic_edge
.build
.expect;
let odom = tree.frame.expect;
let base_link = tree.frame.expect;
let lidar_top = tree.frame.expect;
// One writer per edge, enforced by the claim table rather than by convention.
let w = tree.claim.expect; // (child, parent)
let at_x = ;
w.push.expect; // integer nanoseconds
w.push.expect;
// Compile the route once and evaluate it many times: that is the whole shape of
// the fast path.
let plan = tree.plan.expect;
let g = tree.guard;
// `Stamp` carries its time domain in the type; the annotation pins the default
// `SystemDomain`, because method-call inference does not apply a type
// parameter's default.
let t: Stamp = from_nanos;
match plan.at
let late: Stamp = from_nanos;
match plan.at
Three things in there are deliberate and surprise people:
- Stamps are integer nanoseconds, and they carry a domain in the type. There
is no float-seconds overload. At a 2026 epoch the ULP of
f64seconds is 238 ns, so every interval in a 1 kHz stream is wrong after a round trip. plan()is the object you keep. Compiling the route once and evaluating it many times is the fast path;lookup()by name is the convenience that caches a plan for you.- Errors are
Copyidentifiers that name the offending edge, not formatted strings — a program branches on them, and the prose is a separate layer. They implementDisplayandstd::error::Erroras well, so?intoBox<dyn Error>works and the identifier is still there to match on. The prose layer,Described, is what resolves aFrameIdto the name a human reads.
Linux-first
The single-process engine above is portable Rust. Everything that maps memory —
attaching to a live arena shared with other processes, the frozen .tft
backend — is Linux-only and behind the default-off shm feature. That
sentence is here rather than only in SUPPORT.md because nobody should meet it
as a build error.
Features
| Feature | Default | What it does |
|---|---|---|
counters |
on | The diagnostic counters. Off removes the fields, the increments and the Guard destructor; the arena regions stay, so the layout hash does not fork and the two builds still attach to each other. |
shm |
off | Shared memory: TreeBuilder::build_shared, Tree::attach_shared, tf_tree::open()'s zero-config rendezvous, and the frozen .tft reader. Linux only. |
unstable |
off | Arena-shaped introspection (tf_tree::unstable, Tree::arena_view). Enabling it is the waiver: nothing reachable through it is covered by semver, because its shape follows an arena layout that is scheduled to change. |
test-hooks |
off | One injection point inside Tree::claim, for the repository's own reaper races. Not something a shipped build should carry. |
Shared memory is not a sandbox
Processes sharing an arena are mutually trusting, same-user, cooperating processes. A read-write participant holds a writable mapping of the same pages and can corrupt any part of the arena; no checksum would change that. Three things the design does guarantee, and they are the ones that matter on a robot:
- A read-only participant cannot corrupt anything, enforced by the MMU rather than by convention. It is the default for consumers.
- A participant that crashes, at any instruction, cannot corrupt the arena or wedge anyone else. A killed writer's edge is reclaimed; a killed mutator does not leave a permanently locked topology.
- A participant that hangs cannot be mistaken for a crashed one. Liveness is
the kernel's answer about a file lock, not a heartbeat timeout, so a
SIGSTOPped publisher keeps its claims.
fork() is the sharp edge worth knowing up front: a shared arena is mapped
MADV_DONTFORK, so a child has no mapping and every inherited handle reports
ChildDetached. Open inside the worker. A frozen .tft is the deliberate
exception — it is a private read-only mapping and a child inherits it intact.
Version
0.0.x promises nothing. Cargo treats every 0.0.x release as
incompatible with every other — tf_tree = "0.0.1" means ^0.0.1, which
matches 0.0.1 and nothing else — so a later release reaches no existing
dependant through cargo update. That is the intended signal: pin exactly, and
expect a later release to break. The current number is deliberately not repeated
here — this line said 0.0.1 for three releases, because nothing gates a
version in prose. The reasoning is
written out in the repository's
Cargo.toml under
[workspace.package] version, and the release notes are in
CHANGELOG.md.
MSRV is 1.87; see
SUPPORT.md for
the policy, the response expectations, and what "supported platform" currently
means.
Not everything in the repository ships to crates.io: the tf_tree CLI, the C
ABI and C++ wrapper, the MCAP ingest, the ROS 2 bridge and the Python bindings
are all built from source. cargo install tf_tree installs no command, and it
does not fail either: it exits 0 after warning: none of the package's binaries are available for install using the selected features, naming --features shm
as what the one bin target here needs. That target is
tf_tree_rendezvous_child, the helper tests/rendezvous.rs spawns to prove a
second process joins the arena, so cargo install tf_tree --features shm does
put it in your bin/. It is not a tool and nothing about it is stable. It
carries the crate's name because rendezvous_child is not a name this crate
should own in a shared bin/; it installs at all because every way to install
nothing trades a compile-time guarantee in that test for a path resolved at
run time. Cargo.toml has the measurement.
Where the rest of it is
docs/PROJECT.md— overview, architecture, roadmap, and the decision log.docs/API.md— the cross-cutting contract: six rules every binding obeys.README.md— the offline story (bag → frozen.tft→mmapper dataloader worker), the benchmark policy, and what is and is not implemented.
Licence
Dual MIT / Apache-2.0, at your option. See
NOTICE.