A command-line tool for resolving rigid body transformation chains. Define named coordinate frames and their relationships in a YAML file; the tool walks the frame graph, composes the transforms, and prints the result in whatever rotation representation you choose.
Requirements: CMake ≥ 3.21, a C++20 compiler (GCC 12+, Clang 15+, MSVC 19.34+).
Dependencies are fetched automatically by CMake:
| Library | Version | Purpose |
|---|---|---|
| Eigen | 5.0.1 | Matrix and quaternion arithmetic |
| yaml-cpp | 0.9.0 | YAML parsing |
| GoogleTest | 1.17.0 | Unit tests |
cmake -S . -B build
cmake --build buildThe binary is at build/app/transforms_cli.
To skip building the test suite:
cmake -S . -B build -DTRANSFORMS_BUILD_TESTS=OFF./build/app/transforms_cli example.yamlOutput:
result:
parent: world
child: body
rotation:
type: dcm
convention: passive
matrix:
- [1.00000000, 0.00000000, 0.00000000]
- [0.00000000, 0.00000000, 1.00000000]
- [0.00000000, -1.00000000, 0.00000000]
translation:
frame: parent
direction: parent_to_child
vector: [1.05000000, 0.00000000, 2.12000000]Internally all transforms are stored in a single canonical representation:
- Rotation: passive direction-cosine matrix (DCM)
Csuch thatv_child = C · v_parent - Translation: position of the child frame origin expressed in the parent frame
Every input rotation and translation is converted to this form on load, and every output is converted from it on write. This means you can freely mix representations across frames and outputs.
| Convention | Meaning | Relationship |
|---|---|---|
passive |
The frame rotates; vectors are re-expressed | C (canonical) |
active |
The vector rotates; the frame is fixed | R = Cᵀ |
For a 90° rotation about Z: the passive DCM maps a world +X vector to the child frame as +Y. The active matrix rotates the +X vector to +Y in the same frame. They are transposes of each other.
| Order | Layout | Used by |
|---|---|---|
wxyz |
[w, x, y, z] |
Eigen, most mathematics texts, Hamilton convention |
xyzw |
[x, y, z, w] |
ROS, JPL, many robotics frameworks |
Both orders represent the same mathematical quaternion; only the storage layout differs.
All 12 standard sequences are supported — six Tait-Bryan orders (all three axes distinct) and six proper Euler orders (first and last axis the same):
Tait-Bryan: XYZ XZY YXZ YZX ZXY ZYX
Proper Euler: XYX XZX YXY YZY ZXZ ZYZ
ZYX intrinsic is the aerospace yaw–pitch–roll convention.
Intrinsic vs extrinsic:
| Frame | Axes | Equivalent |
|---|---|---|
intrinsic |
Body-fixed (moving) axes | Standard body-frame composition |
extrinsic |
World-fixed axes | Intrinsic with reversed order and angles |
Extrinsic ZYX [α, β, γ] equals intrinsic XYZ [γ, β, α].
At singular configurations the decomposition into individual angles is not unique. The tool handles all 12 singularity branches:
- Tait-Bryan: middle angle β = ±90° — the tool sets α = 0 and absorbs the constraint into γ
- Proper Euler: middle angle β = 0° or 180° — the tool sets α = 0 and absorbs into γ
The extracted angles will reproduce the correct DCM even though they may differ from the input angles.
Frames are defined relative to a named parent. The tool walks from the requested child frame up to the requested parent, collecting each transform along the path, then composes them with chain():
world → A → B → C (frame definitions)
request: world → C (output)
path: [T_A, T_B, T_C] (collected bottom-up, reversed)
result: T_C ∘ T_B ∘ T_A
Errors are raised for cycles, missing frames, or unreachable paths.
A top-level sequence of named frames, each defined relative to a parent:
transforms:
- transform:
name: my_frame # child frame name (required)
parent: world # parent frame name (required)
rotation: ... # see Rotation section
translation: ... # see Translation sectionFrame names are arbitrary strings. The special frame world (or any name not defined in the transforms list) acts as the root.
The rotation block always requires a type field. All other fields have defaults.
rotation:
type: dcm
convention: passive # passive | active (default: passive)
direction: parent_to_child # parent_to_child | child_to_parent (default: parent_to_child)
matrix:
- [r00, r01, r02]
- [r10, r11, r12]
- [r20, r21, r22]The matrix must be orthonormal with determinant +1 (validated to 1 × 10⁻⁶).
direction: child_to_parent flips the effective convention: a passive child→parent matrix is treated as active parent→child, and vice versa.
rotation:
type: quaternion
convention: passive # passive | active (default: passive)
direction: parent_to_child # parent_to_child | child_to_parent (default: parent_to_child)
order: wxyz # wxyz | xyzw (default: wxyz)
components: [a, b, c, d] # four components in the order specified aboveThe quaternion must be unit norm (validated to 1 × 10⁻⁶). Use at least 9 significant figures to satisfy the tolerance, e.g. [0.984807753, 0.0, 0.0, 0.173648178] for 20° about Z.
rotation:
type: euler
convention: passive # passive | active (default: passive)
direction: parent_to_child # parent_to_child | child_to_parent (default: parent_to_child)
order: ZYX # any of the 12 standard sequences
frame: intrinsic # intrinsic | extrinsic (default: intrinsic)
unit: degrees # degrees | radians (default: degrees)
angles: [α, β, γ] # three angles in the axis sequence orderrotation:
type: angle_axis
convention: passive # passive | active (default: passive)
direction: parent_to_child # parent_to_child | child_to_parent (default: parent_to_child)
unit: degrees # degrees | radians (default: degrees)
angle: 90.0
axis: [0.0, 0.0, 1.0] # need not be unit length — normalised on loadtranslation:
frame: parent # parent | child (default: parent)
direction: parent_to_child # parent_to_child | child_to_parent (default: parent_to_child)
vector: [x, y, z] # defaults to [0, 0, 0]frame specifies the coordinate frame in which the vector components are expressed. direction specifies which origin the vector points away from.
Common combinations:
| frame | direction | Meaning |
|---|---|---|
parent |
parent_to_child |
Child origin in parent coordinates (most common) |
child |
parent_to_child |
Child origin in child coordinates |
child |
child_to_parent |
Lever-arm from child back to parent, in child coordinates |
parent |
child_to_parent |
Lever-arm from child to parent, in parent coordinates |
Use output: for a single result or outputs: for multiple results in one run.
Single output:
output:
parent: world # root of the chain to resolve
child: my_frame # leaf of the chain to resolve
rotation:
type: dcm
convention: passive
# order, frame, unit — only needed for euler output
translation:
frame: parentMultiple outputs:
outputs:
- output:
parent: world
child: link2
rotation:
type: euler
order: ZYX
unit: degrees
translation:
frame: parent
- output:
parent: world
child: end_effector
rotation:
type: quaternion
order: xyzw
translation:
frame: parentThe output rotation block describes only the desired format — no matrix or component data, just type, convention, order, frame, and unit.
Single output — top-level result: key:
result:
parent: world
child: camera
rotation:
type: quaternion
convention: passive
order: xyzw
xyzw: [0.70109644, 0.22667071, 0.31691998, 0.59719838]
translation:
frame: parent
direction: parent_to_child
vector: [100.06660182, 50.06043898, 30.06641783]Multiple outputs — top-level results: sequence:
results:
- result:
parent: world
child: link2
rotation:
...
translation:
...
- result:
parent: world
child: end_effector
rotation:
...
translation:
...The translation is always output as direction: parent_to_child (the canonical direction) in the requested frame.
Three annotated examples are provided in the examples/ directory.
A drone with a nadir camera. The vehicle attitude is given as ZYX Euler angles in degrees (aerospace yaw–pitch–roll). The camera mount rotation is given as an angle-axis. The output is requested as a passive quaternion in xyzw order (ROS convention).
./build/app/transforms_cli examples/drone_camera.yamlA three-DOF robot arm. Each link joint uses a deliberately different rotation representation (DCM → angle-axis → Euler → quaternion) to demonstrate that they are interchangeable. Uses outputs: to return both an intermediate joint pose and the final end-effector pose in a single run.
./build/app/transforms_cli examples/robot_arm.yamlA spacecraft in low Earth orbit with a star tracker mounted on its +Z panel. Demonstrates the active rotation convention and child_to_parent direction on both rotation and translation — the style common in optical instrument calibration files. The spacecraft attitude is given as a passive quaternion in xyzw (ROS/JPL) order. Output is requested as an active angle-axis.
./build/app/transforms_cli examples/spacecraft.yamlcmake --build build --target transforms_tests
./build/tests/transforms_testsThe test suite covers:
- Euler ↔ DCM round-trips for all 12 rotation orders, both intrinsic and extrinsic frames, radians and degrees
- Gimbal-lock (singularity) branches for all 12 orders at both β = +90° and β = −90° (Tait-Bryan) and β = 0° and β = 180° (proper Euler)
- Quaternion ↔ DCM and angle-axis ↔ DCM round-trips
- Cross-representation consistency — the same rotation expressed as a DCM, quaternion, angle-axis, and Euler angles all produce the same canonical transform
- Active vs passive duality across all four representations
to_canonicalandfrom_canonicalfor every input/output typechain()composition including the inverse identity, rotation-into-translation, and multi-hop orderingresolve_pathgraph traversal — single hop, multi-hop, partial chains, same-frame identity, cycle detection, and missing-frame errors- Physical frame chain scenarios — aircraft sensor, three-link robot arm, mixed-representation inputs
transforms/
├── app/
│ ├── main.cpp # Entry point: load → resolve → write
│ ├── spec.hpp # Data types for the parsed YAML file
│ ├── loader.hpp # YAML → spec structs
│ ├── resolver.hpp # Frame graph path resolution
│ ├── writer.hpp # Canonical transform → YAML output
│ └── transforms/
│ ├── types.hpp # Scalar, Vector3, DCM, Quaternion, AngleAxis
│ ├── conventions.hpp # Enums: Convention, EulerOrder, QuaternionOrder, …
│ ├── rigid_transform.hpp # RigidTransform struct (passive DCM + translation)
│ └── conversions.hpp # All rotation conversions + to_canonical / from_canonical / chain
├── tests/
│ └── transforms_tests.cpp # GoogleTest suite (66 tests across 12 suites)
├── examples/
│ ├── drone_camera.yaml
│ ├── robot_arm.yaml
│ └── spacecraft.yaml
├── cmake/
│ ├── Dependencies.cmake # FetchContent for Eigen, yaml-cpp, GoogleTest
│ └── ClangTools.cmake # clang-format / clang-tidy targets
├── example.yaml # Minimal two-frame example
└── CMakeLists.txt
- Single canonical form. All rotation math operates on the passive DCM. Conversions in and out happen only at the I/O boundary (
to_canonical/from_canonical). - No runtime allocation in the math layer.
conversions.hppis entirelyinlinewith fixed-size Eigen types; no heap allocation. - All 12 Euler singularity branches are correct. Each order's gimbal-lock path is independently covered by tests at both +90° and −90° (Tait-Bryan) and 0° and 180° (proper Euler).