# sguaba: a Rust crate that makes coordinate system mix-ups hard to write

> sguaba is a Rust library for rigid body transforms where the coordinate system is part of the type, so a FRD coordinate cannot quietly be used where an ECEF one belongs, and it comes from Helsing, a defence company. It suits engineers writing geospatial and sensor fusion code, and it is a young crate whose ergonomics are still settling.

**helsing-ai/sguaba** — A Rust crate that provides hard-to-misuse rigid body transforms (aka "spatial math") for engineers with other things to worry about than linear algebra.

- Repository: https://github.com/helsing-ai/sguaba
- Stars: 518 · Forks: 27
- Language: Rust
- License: Apache-2.0
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/helsing-ai-sguaba

## What the crate is trying to prevent

sguaba provides hard-to-misuse rigid body transforms, spatial math, for engineers with other things to worry about than linear algebra. That phrase is the design brief, and it is worth unpacking because it says what kind of library this is and is not.

The problem it addresses is not a lack of mathematics. The mathematics of rigid body transforms is standard, and any engineer can write the rotation matrices. The problem is that the same computation is expressible in several coordinate systems, and the type system usually cannot tell you when you have used the wrong one. A vector that is metres long in a body frame becomes something else when you rotate it into the Earth frame, and if the compiler cannot tell the difference then neither can a code review at three in the morning.

So the approach is to make the coordinate system part of the type. The library provides `Coordinate` and `Vector` types for representing points and vectors in coordinate spaces respectively, and they are all generic over a `CoordinateSystem`, so that coordinates from one system cannot easily be incorrectly misused as though they were in a different one.

The `system!` macro allows you to define additional coordinate systems with particular semantics, such as `NedLike` or `FrdLike`, so you can distinguish between coordinates in, say, `PlaneFrd` and `EmitterFrd`. That distinction is the point. A generic `Frd` is a category, and a `system!` invocation turns it into a specific frame belonging to a specific vehicle, so two FRD frames from two different aircraft are two different types.

The project comes from Helsing, whose announcement blog post explains the design in more detail, and the crate is named after the Celtic mythological Sguaba Tuinne, the wave-sweeper, which the README describes as a self-steering boat steered solely by the thoughts of its pilot.

## Five coordinate systems, two families

The README includes a primer on coordinate systems, and it is the part a newcomer should read before touching the API.

The crate supports five main coordinate systems at the time of writing: WGS84, which is latitude and longitude; ECEF, Earth-centered and Earth-fixed; NED, North, East, Down; FRD, Front, Right, Down; and ENU, East, North, Up.

The first two are Earth-bound systems describing points on or near Earth, describing positions relative to the Earth's major and minor axes, often with slightly simplifying assumptions about the Earth's shape. WGS84 does this with latitude and longitude in degrees north and south of the equator and east and west of the prime meridian, while ECEF places a coordinate system at the centre of the Earth and locates the X, Y and Z axes towards specific points on the Earth's surface.

The other three are local systems, descriptions of relative positions to the location of the observer. NED is still Earth-bound in that it describes positions in terms of how far North, East and Down, towards the Earth's core, they are relative to the observer. FRD is a body frame, describing positions relative to the observer's concept of Forward, for example the direction pointing the same way as the nose of a plane, Right, the direction 90 degrees to the right when viewing along Forward, and Down, down through the belly of the plane.

The conversion rules follow directly from that split. Converting between FRD and NED usually requires knowing the orientation of the observer relative to North, East and Down, and converting between NED and ECEF or WGS84 requires also knowing the position of the observer in Earth-bound coordinates.

So there is no single conversion function between any pair of frames, because the geometry does not permit one. Every conversion needs an anchor, either a position or an orientation, and that is why the type system has to carry the frame with the coordinates.

## The system! macro is where a project defines its own frames

The example in the README is a pilot observing something out of a window, and the first thing it does is define two new systems for that pilot's plane:

```rust
// the pilot observes things in FRD of the plane
system!(struct PlaneFrd using FRD);

// the pilot's instruments indicate the plane's orientation in NED
system!(struct PlaneNed using NED);
```

Two declarations, two new types. `PlaneFrd` and `PlaneNed` are not aliases for `FRD` and `NED`; they are distinct types that carry the semantics of this aircraft, and a coordinate in `PlaneFrd` is not the same type as a coordinate in `PlaneNed` or in a second aircraft's frame.

That is the mechanism that makes the misuse hard, and it scales in a way that a plain generic over a marker trait does not. Once you have declared `PlaneFrd`, every function that takes a `Coordinate<PlaneFrd>` says so in its signature, and the compiler will stop you passing an emitter's frame where the plane's frame belongs.

The rest of the example builds the observation and the two anchors. The observation is built from a bearing and a range:

```rust
let observation = Coordinate::<PlaneFrd>::from_bearing(
    Bearing::builder()
        // clockwise from forward
        .azimuth(Angle::new::<degree>(20.))
        // upwards from straight-ahead
        .elevation(Angle::new::<degree>(10.))
        .expect("elevation is in [-90°, 90°]")
        .build(),
    Length::new::<meter>(400.), // at this range
);
```

The builder pattern is used rather than a plain constructor, and the `expect` on the elevation documents the valid range. The units are in the type as well, through `Angle::new::<degree>` and `Length::new::<meter>`, so a range in metres cannot be handed to a function expecting a degree count.

The world anchor is the WGS84 position, built the same way, with latitude, longitude and altitude each carrying its own unit and range check.

## Two APIs for the same conversion: engineering and mathematical

The crate splits its interface into two modules, and the README explains why with a nice observation: technically the mathematical constructs are all you need to express anything in coordinate system math, including poses and orientation, because everything is an isometry if you think hard enough about it. But if your brain is more used to thinking about orientation and poses, position plus orientation, you will want the `engineering` module with easier-to-grasp types like `Pose` and `Orientation`.

So the `math` submodule holds the raw transforms and rotations, and the `engineering` module holds the domain-flavoured wrappers. The examples directory makes the split concrete, with `pilot-as-engineer.rs` and `pilot-as-mathematician.rs` as the two versions of the same computation, plus `target-acquisition-and-confirmation.rs` for a scenario that runs the whole chain.

The engineering path starts from the orientation, and the README notes the anchors required at each step:

```rust
let orientation_in_ned = Orientation::<PlaneNed>::from_tait_bryan_angles(
    Angle::new::<degree>(8.),  // yaw
    Angle::new::<degree>(45.), // pitch
    Angle::new::<degree>(0.),  // roll
);
```

Tait-Bryan angles, that is yaw, pitch and roll, and the orientation is expressed relative to North, East and Down, which is the frame the plane's instruments report in. Note that the type parameter is `PlaneNed`, the plane-specific local frame, not the generic `NED`.

The README then states the two anchors plainly: you must know the observer's body orientation relative to NED to go from FRD to NED, and the observer's ECEF position to go from NED to ECEF. The example builds the ECEF to plane NED transform for exactly that reason, and the comment in the code explains that the transform depends on where on the globe you are, so it takes the WGS84 position, with a safety comment asserting that the WGS84 value is the location of the plane's NED origin.

The interesting thing in that snippet is that it is an `unsafe` block. The transform cannot be constructed safely from the data alone, because the geometry depends on an assumption the type system cannot check, and the library makes that assumption explicit in the source rather than hiding it.

## The dependency stack: nalgebra, uom and typenum

The `Cargo.toml` shows a dependency list that explains a lot about the design.

`nalgebra` at 0.35.0 with `default-features = false` is the linear algebra layer, and `uom` at 0.38.0 with the `autoconvert`, `si` and `f64` features is the unit and quantity layer, which is what gives you `Angle::new::<degree>` and `Length::new::<meter>` with the unit carried in the type. `typenum` at 1.18.0 supplies the type-level arithmetic that the quantity types need, and `approx` at 0.5.1 is the optional approximate comparison support, which is how a test asserts that two floats are close without writing a tolerance by hand.

The feature flags are tidy. The defaults are `std`, `serde` and `approx`. The `libm` feature pulls in the `libm` crate and nalgebra's libm support, and that combination exists for no-std targets where the Rust standard library's floating point functions are not available. The `serde` feature enables serde's derive plus nalgebra's serde-serialise and uom's serde support, so the coordinate types can be serialised.

The `std` feature simply forwards to nalgebra, uom and serde. So a no-std embedded build is a matter of turning off the defaults and turning on `libm`, which is a real design decision rather than an accident of the dependency versions.

The package is `sguaba` at version 0.10.7, edition 2024, with `rust-version = "1.89.0"` and the license set as `MIT OR Apache-2.0`, the dual licence convention that lets an organisation pick whichever it prefers. The keywords are `spatial`, `rigid-body`, `coordinates` and `coordinate-systems`, and the categories are `aerospace` and `mathematics`, which is a fair description of who the crate is aimed at.

The dev dependencies are also revealing about how the project is verified: `rstest` for parameterised tests, `insta` for snapshot testing, `quickcheck` for property-based testing, `criterion` for benchmarks with HTML reports, and `serde_yaml` for serialisation tests. There is a `benches/` directory and the `Cargo.toml` declares a benchmark named `ecef` with `harness = false`.

## Where sguaba is the wrong choice

The first wrong-tool case is a script that converts a handful of coordinates. The generic coordinate types, the `system!` macro, the two APIs and the unit-carrying quantities are a considerable amount of ceremony for a one-off conversion, and a general purpose linear algebra library will get there faster.

The second is a codebase that does not have this problem. The type-level separation earns its keep when you have many frames, many sensors and many authors, because that is when a mislabelled frame actually reaches production. A team with one local frame and one world frame will find the friction larger than the benefit.

The third is a project that needs a general 3D stack. nalgebra and uom do the heavy lifting here, and if you are already using a linear algebra crate for your own matrices, adding a second opinion about rotations means you will be converting between representations at the boundary. The two APIs in this crate, one mathematical and one engineering flavoured, are the same kind of choice made once rather than twice.

The fourth is a strict no-std embedded target without a libm port. The `libm` feature exists for that case, but it means your platform needs a `libm` implementation available, and the crate does not say which platforms are supported.

The alternative worth naming is writing the rotation and transform code directly on nalgebra, which is the approach most teams reach for first, and the difference in approach is that nalgebra gives you the mathematics without the opinions, while sguaba gives you the mathematics plus the type discipline, the unit handling and the two domain-flavoured APIs, at the cost of learning a vocabulary that is specific to this crate. A team that keeps making frame mistakes is the one for which that trade is worth making.

## Maintenance, licensing and what to check first

The repository is not archived, and the last push to the default branch was on 2026-10-01. The project has no GitHub releases, so the version history lives in the `CHANGELOG.md` at the top of the tree.

At version 0.10.7 this is a young crate, and the version number says so. It is under a 1.0 release, which under Rust's semver rules means the API can still change in breaking ways in a minor version. The `Cargo.toml` carries a comment warning the maintainers about exactly that, on the `rust-version` field: if you increase it, update the `msrv` job in `.github/workflows/check.yml`. That is a small thing, but it is a good sign about how the project is run, because the minimum supported Rust version is checked in CI rather than being a claim in the readme.

The licence is dual, `MIT OR Apache-2.0`, with both `LICENSE-APACHE` and `LICENSE-MIT` files in the tree. That is the standard Rust convention and it means a consuming organisation can pick the licence it prefers without asking. This is a description of the published terms and not legal advice.

The repository also carries `CHANGELOG.md`, a `Cargo.lock`, and the `examples/`, `benches/` and `src/` directories. There is no `CLAUDE.md` or agent instruction file at the top level, which is slightly unusual for a current project and suggests the crate was started before that convention spread.

For an adopter, the first thing to do is read the generated docs on docs.rs, because the README explicitly says to prefer them. Then pick a single frame pair and do the conversion once by hand with the `engineering` API, because the type discipline only becomes obvious once you have tried to pass the wrong thing and been stopped by the compiler.

## Conclusion

Adopt sguaba when you are writing Rust code that converts between geospatial frames and the mistakes you are worried about are unit or frame mix-ups, since the coordinate system is carried in the type and the crate supplies the rotations and transforms you would otherwise hand-roll. Do not adopt it for a small script, since the generics and the `system!` macro are a real learning cost against a handful of coordinate conversions, and do not adopt it expecting the orientation and position types to be interchangeable with a general purpose linear algebra library. Before you start, read the generated docs on docs.rs, check that your Rust toolchain meets the 1.89.0 minimum, and decide which optional features you need, since `std`, `serde` and `approx` are on by default while `libm` exists for no-std targets.

## FAQ

### What is the sguaba crate and what problem does it solve?

sguaba provides hard-to-misuse rigid body transforms, spatial math, for engineers with other things to worry about than linear algebra. Its `Coordinate` and `Vector` types are generic over a `CoordinateSystem`, so coordinates from one system cannot easily be misused as though they were in a different one.

### Which coordinate systems does sguaba support?

Five main ones: WGS84 for latitude and longitude, ECEF for Earth-centered Earth-fixed, and the local systems NED, FRD and ENU. The README's primer explains that WGS84 and ECEF are Earth-bound while NED, FRD and ENU are local descriptions relative to the observer.

### What is the system! macro for in sguaba?

It lets you define additional coordinate systems with particular semantics, such as `NedLike` or `FrdLike`, so you can distinguish between coordinates in, say, `PlaneFrd` and `EmitterFrd`. Each invocation creates a new type rather than an alias, so two frames of the same family from different objects do not mix.

### What is the difference between the math and engineering modules in sguaba?

The `math` submodule holds the rigid body transforms and rotations, which are enough to express anything in coordinate system math. The `engineering` module offers easier-to-grasp types like `Pose` and `Orientation` for people who think in terms of position and orientation, and the examples directory shows the same computation done both ways.

### What licence and Rust version does sguaba require?

The package is licensed `MIT OR Apache-2.0` with both licence files in the repository, and it declares `rust-version = "1.89.0"` on edition 2024. The default features are `std`, `serde` and `approx`, with `libm` available for targets without the standard library's floating point support.

## Sources

- [helsing-ai/sguaba on GitHub](https://github.com/helsing-ai/sguaba)
- [Issues](https://github.com/helsing-ai/sguaba/issues)
- [License: Apache-2.0](https://github.com/helsing-ai/sguaba/blob/main/LICENSE)
- [README](https://github.com/helsing-ai/sguaba/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/helsing-ai-sguaba
