Skip to main content

Crate tf_tree

Crate tf_tree 

Source
Expand description

std facade for the tf_tree transform engine.

Re-exports the tf_tree_core engine and adds the ergonomic, allocating conveniences that do not belong in the no_std core: the TreeBuilder and the Tree that owns a HeapArena, the plan-cached Tree::lookup, and Described — a Display wrapper that resolves error ids to frame names by consulting the arena (the error type itself stays Copy and no_std).

Most users depend on this crate, not on tf_tree_core directly.

use tf_tree::{TreeBuilder, InterpPolicy, Stamp, Iso3};

// Topology is declared on the builder; `build()` sizes the arena from exactly
// these edges (static edges reserve no ring slots).
let tree = TreeBuilder::new()
    .static_edge("map", "odom", &Iso3::IDENTITY)
    .build()
    .expect("layout");

// map -> odom is a static identity, so the lookup is identity at any time.
// A typed binding pins the default `SystemDomain` (method-call inference does
// not apply a type parameter's default, so annotate the stamp once).
let now: Stamp = Stamp::from_nanos(0);
let t = tree.lookup("map", "odom", now).unwrap();
assert_eq!(t, Iso3::IDENTITY);

§Minimum supported Rust version

1.87. It is declared in [workspace.package] rust-version and repeated here because a manifest is not somewhere a user reads: the person deciding whether they can adopt this crate opens the docs, and cargo refusing to build is a worse way to find out. just msrv builds --locked on exactly that toolchain and fails if this line, README.md, SUPPORT.md or any hand-written rust-version disagrees with the manifest.

An MSRV bump is a minor-version bump pre-1.0 and a breaking change after — SUPPORT.md is the policy, including why each of the two steps so far was forced by a dependency rather than chosen.

§Two stability tiers

Everything at this crate’s root is the stable surface: at a published tag each pub item is a semver promise. The tf_tree::unstable module — behind the default-off unstable feature, so it is absent from these docs unless that feature is on — is not, and enabling the feature is the waiver (docs/API.md §2.6). It mirrors the C ABI’s tf_tree.h / tf_tree_unstable.h split, which is the same promise spelled as two headers.

What lives there is what the arena layout shapes, because that layout is scheduled to change (docs/PHASE5.md §1). If you are reading transforms, you will never need it.

Gating a door is not the same as removing a room. The question the gated Tree::arena_view used to be the only Rust answer to — what is in this tree? — is answered on the stable tier by Tree::frames and Tree::edges, which mirror Python’s tree.frames() / tree.edges() (docs/API.md §3.2). Names only: the statistics half is docs/PHASE5.md §4.2’s and is held back on every surface until §3’s counting pass. Enabling unstable buys the arena-shaped spelling of that answer — record fields, capacities, counters — not the answer itself.

The three items that moved do not answer at the crate root any more, and this is what pins that — but only when the feature is on, and where that holds moved in 0.0.1. It used to be every cargo test here, because the crate dev-depended on itself to enable unstable; that line did not survive cargo package and is gone. Today the assertion means “moved to tf_tree::unstable” under cargo test --doc --workspace, which unifies the feature in from the four consumers that declare it, and degrades to the weaker “absent from the crate root” under a bare -p tf_tree. Both readings are true; just test runs the strong one:

use tf_tree::ArenaView;
use tf_tree::EdgeKind;
use tf_tree::EdgeMeta;

Three blocks and not one use tf_tree::{ArenaView, EdgeKind, EdgeMeta};, because a single block passes as soon as any one of the three is absent — it would go on passing after a refactor put two of them back.

E0432 and not a bare compile_fail: an unpinned one passes when the snippet fails for any reason, and stable rustdoc ignores the code, so just test-doc-error-codes is this line’s real gate (justfile).

§What the pre-tag audit left alone, and why

The sweep behind the split asked docs/API.md §7 of every pub item here. Three moved; the rest stay, and two answers are worth recording because they look like omissions:

  • EdgeWriter still carries a lifetime, which §2.1 calls a violation. It is a known one and it is not the bug: OwnedWriter is the storable shape, and a scoped claim whose scope the borrow checker enforces is better when it fits. §2.1 says so in terms.
  • Described’s two fields became private. They promised that the Display wrapper is exactly (error, tree) forever, for no caller — the only construction site in the workspace is Tree::describe.

tf_tree_core’s crate docs carry the rule the audit applied to #[non_exhaustive], and the per-type arguments sit on the types.

§no_std / std split

Everything arena-generic — Plan, Step, Guard, Stamp, Domain, Query, the compile/evaluate engine — lives in the no_std tf_tree_core. This crate adds only what needs std: the concrete Tree owning a heap arena, the per-thread plan cache behind Tree::lookup (thread_local!), and Described’s Display.

§Tree is not Clone, and Arc<Tree> is the embedding idiom

Tree is Send + Sync, so a shared reference is all a reader needs — but it is deliberately not Clone, and the reason is that a Tree is not just a handle. It owns its arena backing and holds a registered slot in the arena’s participant table — a fixed-size table (DEFAULT_MAX_PARTICIPANTS, 64) sized when the arena is created, not an unbounded pool. A derived Clone would have to pick one of two wrong answers: register a second slot, and burn a scarce resource every time somebody passed a tree by value; or share the first one, and report two participants as one to the reaper that decides whether a slot’s owner is still alive.

So share it with an Arc:

use std::sync::Arc;
use tf_tree::{Iso3, Stamp, TreeBuilder};

let tree = Arc::new(
    TreeBuilder::new()
        .static_edge("map", "odom", &Iso3::IDENTITY)
        .build()
        .expect("layout"),
);
let reader = Arc::clone(&tree);
let joined = std::thread::spawn(move || {
    let now: Stamp = Stamp::from_nanos(0);
    reader.lookup("map", "odom", now)
})
.join()
.expect("reader thread");
assert_eq!(joined.unwrap(), Iso3::IDENTITY);

This is not new advice, which is the point of writing it down: tests/tsan.rs shares a tree between threads this way, tf_tree_c hands out Arc<TreeShare> (a one-field wrapper around a Tree, so the refcount is on the wrapper rather than on the Tree itself), and PyO3’s Py<PyTree> is the same refcount spelled in CPython’s allocator. Three surfaces arrived here independently and none of them said so where an embedder would look (docs/API.md §2.2).

§Set lto = "thin" and codegen-units = 1 in your release profile

[profile.release]
lto = "thin"
codegen-units = 1

This is worth about 25% of a depth-3 lookup, and it is not cargo-cult advice — it is a property of where this engine’s code lives. Plan::at sits across a crate boundary from every consumer, and it and the fold beneath it live one crate further down still, in tf_tree_core. Five functions on the evaluate path carry #[inline] for exactly that reason (Plan::at, the scalar fold, and the three Guard sampling entry points), but what an attribute buys depends on your profile, not on ours: cargo’s --release defaults are lto = false, codegen-units = 16, and this workspace’s are not, so every latency number this project publishes is taken under whole-program optimisation and your node’s is not.

Measured rather than asserted, because the last claim made here about this mechanism was wrong in a way only a probe could show. One program — a depth-3 map <- imu_link lookup, LerpSlerp, off-grid stamps so the interpolation runs, one lookup per non-inlinable call — built twice and pinned to one core, nine rounds each, three consecutive runs:

downstream profilens/lookup
lto = false, codegen-units = 16 (cargo’s --release default)240
lto = "thin", codegen-units = 1193–195

On a 4-physical-core AMD EPYC-Milan VM under moderate load, 2026-08-02, so read it as “about a quarter”, not as three digits — the ratio itself moved between 1.19× and 1.24× across those runs.

The same runs also say why, which is the part that makes this advice rather than folklore. They time a second, identical body compiled inside tf_tree_core, and compare it against the one outside:

downstream profilefrom outside the enginefrom inside it
lto = false, codegen-units = 16240 ns191 ns
lto = "thin", codegen-units = 1193 ns194 ns

At cargo’s defaults the crate boundary costs about a quarter of the lookup; with thin LTO it costs nothing measurable, because the boundary is gone at link time. just embed-cost in this repository re-measures both, and docs/PHASE5.md §9.2 makes the second one a standing, gated benchmark row so the next change to those attributes moves a number somebody sees.

The cost of taking this advice is build time: thin LTO adds a link-time optimisation pass, and codegen-units = 1 gives up intra-crate build parallelism. Both are compile-time costs and neither changes what the shipped binary computes. How the 25% splits between the two settings has not been measured here, so if your release builds are slow enough that you want to take only one of them, measure your own case rather than trusting a guess from this paragraph.

Modules§

dualquat
Fast SE(3) screw interpolation via unit dual-quaternion powers.
unstable
The unstable tier — docs/API.md §2.6. Enabling the unstable feature is the waiver; read the module’s own documentation for what it waives. The unstable tier. Nothing in this module is covered by semver.

Structs§

AdaptiveScratch
Caller-provided scratch storage for Plan::at_adaptive, sized to hold the maximum knot set. Allocated once by the caller (its allocation is not counted against at_adaptive, which never allocates globally).
Capacity
Ring capacity for one dynamic edge, always a power of two.
Described
A LookupError paired with the Tree that can resolve its ids to names.
EdgeCfg
Per-edge configuration for TreeBuilder::dynamic_edge.
EdgeId
Stable identity of an edge (index into the edge table).
EdgeWriter
A claimed edge: the arena record, and the lease that makes its holder’s death observable (docs/PHASE2.md §6.1, docs/decisions/0005 §5).
ErrBound
The per-component error tolerance for Plan::at_adaptive.
Extrapolated
A pose, and how far past the plan’s newest common sample it was extrapolated.
FrameId
Stable identity of a frame.
FrozenHeader
The .tft container header — docs/PHASE5.md §2.3, NORMATIVE.
Guard
A batch-evaluation handle: it borrows the arena and pins the topology generation once, so a run of lookups validates against a single snapshot.
Iso3
A rigid-body transform in SE(3): a rotation q followed by a translation t. T_parent_child — applying it to a point in child yields the point in parent.
LerpSlerp
tf2-compatible interpolation: translation LERP + rotation SLERP.
Open
The docs/PHASE2.md §3.2 builder.
OwnedWriter
An EdgeWriter that owns its tree — the claim shape for a writer that is stored rather than scoped (docs/decisions/0017, docs/API.md §2.1).
Plan
A compiled lookup(target, source) path.
Publisher
Exclusive writer handle for one edge.
Quat
Unit quaternion, Hamilton convention, scalar (w) first.
Sample
A pose and its derivatives at one instant — docs/PHASE4.md §2.2.
ScLerp
SE(3) screw-geodesic interpolation — the default policy.
SensorDomain
A sensor’s own clock (e.g. a lidar or camera timestamp), tag 1. Distinct from SystemDomain so a stamp from one cannot be used to query the other.
SimDomain
Simulated time — a /clock publisher, a bag replay, or a physics engine — tag 2.
Stamp
A nanosecond timestamp in domain D.
SteadyDomain
A steady, monotone clock (CLOCK_MONOTONIC-like), tag 3.
SystemDomain
The default domain: the host system clock (CLOCK_REALTIME-like), tag 0.
Tree
A transform tree: a fixed-capacity arena plus the ergonomic operations for publishing samples and looking up transforms. Build one with TreeBuilder.
TreeBuilder
Builder for a Tree.
Twist
A body-frame (right) twist: angular velocity ω (rad/s) and linear velocity v (m/s), both expressed in the moving frame.
Vec3
A point or vector in R³.

Enums§

AttachMode
Shared-memory attachment surface (Phase 2). Linux-only, behind --features shm. How a process attaches to an existing segment.
AwaitError
Why Tree::await_frames could not produce ids.
BuildError
Failure building a Tree from a TreeBuilder.
ClaimApiError
Failure claiming an edge for writing.
ClaimError
A failed attempt to claim exclusive write access to an edge.
CreatePolicy
Re-exported so a caller does not have to depend on tf_tree_ipc directly just to name a policy open() already takes. What open() should do when no arena exists.
ExtrapPolicy
What to do when the requested stamp is newer than every published sample.
FrameError
A failed frame interning.
FrozenError
Why a .tft could not be written or opened.
FrozenFileError
Why a .tft could not be opened or written.
Inheritance
How Tree::inherit_ownership resolved (§3.5).
InterpPolicy
Selects an interpolation policy at runtime from an edge’s stored discriminant.
Layout
How a transform is written into a caller’s buffer.
LookupError
A lookup or sample failure.
OpenError
Why Open::open could not produce a Tree.
PushError
A failed push onto an edge’s sample ring.
Query
A temporal query against a compiled Plan.
ReparentError
Failure re-parenting a frame at runtime.
ShmError
Shared-memory attachment surface (Phase 2). Linux-only, behind --features shm. Everything that can go wrong obtaining or validating a shared segment.
Step
One step of a compiled plan.

Constants§

ARENA_FILE_ALIGN
Alignment of the arena image within the file (§2.3).
CRASH_SITES
Every docs/PHASE2.md §11.3 crash point compiled into this crate.
MAX_ADAPTIVE_DEPTH
Maximum bisection recursion depth in Plan::at_adaptive.
MAX_DEPTH
Maximum length of a compiled plan: the number of plan::Step slots a plan::Plan carries, counted after constant folding.
MAX_KNOTS
Maximum number of knots Plan::at_adaptive may emit.
MAX_PATH_EDGES
Maximum number of raw path edges plan::compile will walk, counted across both sides of the lowest common ancestor before folding.

Traits§

Domain
A time domain: a compile-time marker carrying a runtime Domain::TAG byte.
Interp
Interpolate between two poses a (at s = 0) and b (at s = 1).

Functions§

arena_format_version
This build’s arena format version (docs/PHASE5.md §1).
arena_layout_hash
This build’s arena layout hash — the geometry, as distinct from the format version’s set of fields. Both are checked on attach.
counters_compiled_in
Whether this build compiled docs/PHASE5.md §5’s diagnostic counters in.
exp_se3
SE(3) exponential: map a twist ξ = [ω, v] to a rigid transform.
exp_so3
SO(3) exponential: map a rotation vector ω (axis × angle) to a unit quaternion.
log_se3
SE(3) logarithm: map a rigid transform to its twist ξ = [ω, v].
log_so3
SO(3) logarithm: map a unit quaternion to its rotation vector ω in the principal branch |ω| ∈ [0, π].
open
Join the running arena, read-only.
quat_from_rot3
The unit quaternion of a row-major 3×3 rotation matrix.
slerp
Shortest-arc spherical linear interpolation of two unit quaternions.
write_affine32
Write iso as a row-major 3x4 f32 affine.
write_mat4
Write iso as a row-major 4x4 f64 matrix.
write_quat
Write iso as [qw qx qy qz tx ty tz].
write_quat_twist
Write iso and twist as [qw qx qy qz tx ty tz | ωx ωy ωz vx vy vz].