Skip to content

zarch/grass-gis

Repository files navigation

grass-gis

⚠️ Experimental — use at your own risk.

This crate is in active development. The API has no stability guarantees: types, method signatures, and module structure can change in any release without prior notice. It is shared publicly for experimentation and feedback, not for production use.

The underlying GRASS GIS C library relies heavily on process-level global state. While this crate wraps it behind an ergonomic async API, it cannot eliminate all sources of undefined behaviour or subtle interaction effects. You are expected to understand the constraints described below before building on top of this library.

Experimental, high-level async Rust bindings for GRASS GIS 8.x.

grass-gis provides an ergonomic Rust interface around the GRASS GIS C library, sitting between grass-gis-sys (raw FFI via bindgen) and your application code.

Architecture (Async Client/Worker)

All GRASS C calls execute on a single dedicated OS thread (the worker). Your code interacts through GrassClient — an async, Send + Sync, cheaply cloneable handle. Operations are submitted via async channels; results return via one-shot channels.

your code (any thread)
    │
    ├── client.raster_read_f64_row(...)  ──┐
    ├── client.vector_write_line(...)      ──┤  async mpsc channel
    └── client.shutdown()                  ──┤  (OpRequest)
                                            │
                                  ┌─────────▼─────────┐
                                  │   Worker Thread    │
                                  │  (single OS thread)│
                                  │                    │
                                  │  dispatch(op) {    │
                                  │    match op.kind { │
                                  │      GisOp::...    │
                                  │      RasterOp::... │
                                  │      VectorOp::... │
                                  │      ...           │
                                  │    }               │
                                  │  }                 │
                                  └─────────┬─────────┘
                                            │  oneshot channel (OpReply)
                                  ┌─────────▼─────────┐
                                  │  await result      │
                                  └───────────────────┘

This design confines the GRASS C library's global state to one thread, while your code stays fully concurrent. It does not make the GRASS C library memory-safe — it manages the access pattern.

Entry Point

use grass_gis::{GrassRuntime, gis::Region};

#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
    let handle = GrassRuntime::new("my_module")
        .capacity(16)
        .spawn()?;

    let client = handle.client().clone();

    let region = Region::current(&client).await?;
    println!("{}x{} cells", region.cols, region.rows);

    handle.shutdown_and_join().await?;
    Ok(())
}

Features

Module Description
gis Region management, projections, location/mapset queries
raster Type-safe raster I/O (CELL/FCELL/DCELL), null values, colors, categories, history
vector Type-safe vector I/O, features, topology queries, spatial finders, bounding boxes
segment Random-access raster tiling via Segment<T>
db SQL execution, cursor-based queries, attribute row access
parser GRASS command-line argument parsing
mapset Mapset/location queries, search path management
error Result<T, GrassError> error handling (16 variants)

Fatal Error Recovery

Many GRASS C functions call G_fatal_error() which would normally terminate the process. The worker catches these via longjmp (through FatalGuard) and returns Err(GrassError::Fatal{..}) instead — your application keeps running.

This mechanism is best-effort: G_fatal_error paths that allocate or hold locks before jumping may leave internal GRASS state inconsistent. Treat a fatal error as a signal to shut the session down rather than to continue operating normally.

Examples

Raster I/O

use grass_gis::{GrassRuntime, RasterReader, RasterType};

#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
    let handle = GrassRuntime::new("raster_example").spawn()?;
    let client = handle.client().clone();

    // Read metadata
    let meta = grass_gis::RasterMetadata::read(&client, "elevation", "")
        .await?;
    println!("{}x{} ({:?})", meta.rows, meta.cols, meta.cell_type);

    // Open and read rows
    let reader = RasterReader::open(&client, "elevation", "").await?;
    for row in 0..reader.rows() {
        let data = reader.read_f64_row(row).await?;
        // ... process row data ...
    }

    // Create and write a new raster
    let mut writer = grass_gis::RasterWriter::create(
        &client, "output_map", RasterType::DCell,
    ).await?;
    let row_data: Vec<f64> = (0..writer.cols() as usize)
        .map(|i| i as f64 * 1.5).collect();
    writer.write_f64_row(0, &row_data).await?;
    writer.flush().await?;
    writer.close().await?;

    reader.close().await?;
    handle.shutdown_and_join().await?;
    Ok(())
}

Vector I/O

use grass_gis::{GrassRuntime, VectorMapClient, FeatureType, PointsData, CatsData};

#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
    let handle = GrassRuntime::new("vector_example").spawn()?;
    let client = handle.client().clone();

    // Open existing vector map
    let map = VectorMapClient::open_readonly(&client, "roads", "").await?;
    println!("Lines: {}, Areas: {}", map.num_lines().await?, map.num_areas().await?);

    // Iterate features
    let mut iter = map.iter_features();
    while let Some(feature) = iter.next().await? {
        println!("{:?}: {} points", feature.ftype(), feature.num_points());
    }
    map.close().await?;

    // Create a new map and write features
    let mut out = VectorMapClient::open_write(&client, "output", false).await?;
    let points = PointsData::new_2d(vec![100.0, 150.0], vec![200.0, 250.0]);
    let mut cats = CatsData::new();
    cats.push(1, 1);
    out.write_line(FeatureType::LINE, points, cats).await?;
    out.build().await?;
    out.close().await?;

    handle.shutdown_and_join().await?;
    Ok(())
}

Segment (Random-Access Tiling)

use grass_gis::{GrassRuntime, segment::Segment};

#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
    let handle = GrassRuntime::new("segment_example").spawn()?;
    let client = handle.client().clone();

    let seg: Segment<f64> = Segment::create(&client, 100, 100, 64, 64, 4).await?;

    seg.put(0, 0, 3.14).await?;
    let val = seg.get(0, 0).await?;
    assert_eq!(val, 3.14);

    // Batch row I/O
    let row: Vec<f64> = (0..100).map(|i| i as f64).collect();
    seg.put_row(1, &row).await?;
    let readback = seg.get_row(1).await?;

    seg.close().await?;
    handle.shutdown_and_join().await?;
    Ok(())
}

Database / Attributes

use grass_gis::{GrassRuntime, VectorMapClient, DbDriver};

#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
    let handle = GrassRuntime::new("db_example").spawn()?;
    let client = handle.client().clone();

    // Query attributes for a vector map with an attribute table
    let map = VectorMapClient::open_readonly(&client, "my_points", "").await?;
    let rows = client.query_attributes(
        map.handle(), 1,
        "SELECT cat, name, value FROM my_points WHERE value > 0.0",
    ).await?;

    for row in rows {
        println!("cat={:?} name={:?} value={:?}",
            row.get_i64("cat"),
            row.get_str("name"),
            row.get_f64("value"),
        );
    }

    // Direct SQL execution via DbDriver
    let field_info = client.field_info(map.handle(), 1).await?
        .expect("expected dblink on layer 1");
    let driver = DbDriver::open(&client, &field_info).await?;
    let mut cursor = driver.open_select(
        "SELECT * FROM my_points ORDER BY cat"
    ).await?;
    while let Some(attr_row) = cursor.fetch().await? {
        println!("{:?}", attr_row.values());
    }
    cursor.close().await?;
    map.close().await?;
    handle.shutdown_and_join().await?;
    Ok(())
}

r.slope.aspect (GRASS Command Equivalent)

A full Rust reimplementation of the GRASS r.slope.aspect module, demonstrating how to build complete GRASS workflows using the async API.

# Build and run
export GISBASE=/path/to/grass86
export LD_LIBRARY_PATH=$GISBASE/lib:$LD_LIBRARY_PATH
export GISDBASE=/tmp/grass_test_db LOCATION_NAME=test_location MAPSET=test_mapset
export INPUT_MAPSET=test_mapset

cargo run --release --example r_slope_aspect -- \
    elevation=test_elevation_f64 slope=rslope aspect=raspect \
    dx=rdx dy=rdy precision=dcell

Outputs slope, aspect, curvatures, and first/second partial derivatives from an elevation raster using Horn's 3×3 finite-difference window. All 9 output types match the C implementation exactly on projected coordinate systems.

Limitation: Geodesic distance (G_distance()) is not yet exposed in grass-gis, so latlon projections use a planar approximation for H/V distance factors. Results match the C version on projected (UTM, metric) data.

Performance

Benchmarked against the reference C implementation of r.slope.aspect on grids from 4 000 × 4 000 to 16 000 × 16 000 cells (FCELL and DCELL output, 10 timed runs each, median wall time):

Scenario Wall time vs C
Rust, 1 thread 1–23 % slower
Rust, best thread count 12–35 % faster

Single-threaded Rust carries a small overhead from the async channel protocol and Tokio runtime. With 2–8 worker threads the parallelised row pipeline outpaces the C implementation across all tested grid sizes.

Wall time: C vs Rust

See benchmark_results/ for the full dataset and instructions on reproducing the benchmark.

Scripts

Script Description
scripts/benchmark.py Benchmark runner and summary (run / summary subcommands)
scripts/benchmark_slope_aspect.sh Compare Rust vs C performance and output
scripts/setup_benchmark_data.sh Generate synthetic elevation rasters for benchmarking

Prerequisites

  • Rust 1.94+ (edition 2024)
  • GRASS GIS 8.x installed locally (tested with 8.6)
  • C linker must find libgrass_gis.so — configure in .cargo/config.toml:
[target.x86_64-unknown-linux-gnu]
rustflags = ["-L", "/path/to/grass86/lib"]

Environment Variables

Variable Required Description
GISBASE Yes (build + run) Path to GRASS installation
LD_LIBRARY_PATH Yes (build + run) Must include $GISBASE/lib
GISDBASE Test only GRASS database directory (default: /tmp/grass_test_db)
LOCATION_NAME Test only Location name (default: test_location)
MAPSET Test only Mapset name (default: test_mapset)
GRASS_SKIP_INTEGRATION Optional Set to skip integration tests

Building

export GISBASE=/path/to/grass86
export LD_LIBRARY_PATH=$GISBASE/lib:$LD_LIBRARY_PATH

# Release builds may need:
export BINDGEN_EXTRA_CLANG_ARGS="-I$GISBASE/include"

cargo build

Testing

# All tests (test-threads=1 is enforced automatically via .cargo/config.toml)
cargo test

# Unit tests only (no GRASS installation needed)
GRASS_SKIP_INTEGRATION=1 cargo test

# Specific test
cargo test test_raster_reader_dimensions

# Run doc-tests from README and source code
cargo test --doc

How Testing Works

Each integration test spawns its own dedicated GRASS worker with an isolated temporary mapset. The .cargo/config.toml file enforces test-threads = 1 because GRASS C maintains process-level global state (SQLite driver connections, G_ globals) that can race when multiple worker threads are alive simultaneously.

Doc-Tests

The README code snippets are marked with rust,no_run (they compile but don't execute — they need a live GRASS installation). You can verify they compile by running:

cargo test --doc

This compiles every no_run code block in the README and in src/**/*.rs doc comments without executing them. Snippets marked ignore (which require runtime state like an active worker) are skipped by cargo test.

Limitation: Full execution of doc-tests is not practical because every operation requires a running GRASS worker thread (with GISBASE set, a valid GISDBASE, etc.). Use the integration test suite (cargo test) for end-to-end verification against a real GRASS installation.

Known Limitations

  • Single session per process. GRASS C globals (G_*, SQLite driver handles) are process-wide. Running two GrassRuntime workers concurrently is untested and likely to corrupt state.
  • No Windows support. GRASS GIS does not ship shared libraries for Windows.
  • longjmp-based fatal recovery is best-effort. See Fatal Error Recovery above.
  • API coverage is partial. Imagery, 3D raster, and display modules are not yet wrapped. See API Coverage below.
  • latlon projections use planar approximation for H/V distance factors in the slope/aspect example.

API Coverage

GRASS Module C Functions Status Notes
gis (G_*) 634 ✅ Core init, region, projection, messaging
raster (Rast_*) 389 ✅ Core read/write, null, colors, categories, history
vector (Vect_*) 358 ✅ Core open/close, read/write, build, topology, spatial queries
segment (Segment_*) 11 ✅ Core open/close, get/put cell, get/put row, flush
db (db_*) 412 ✅ Core SQL execution, cursor fetch, column metadata
parser (G_parser) ✅ Core command-line argument parsing
imagery (I_*) 188 🔜 Planned
raster3d (Rast3d_*) 242 🔜 Planned

Key Types

Session (standalone tools)

Use GrassSession when launching a GRASS tool as a standalone binary outside an active GRASS shell session. It bootstraps the environment, writes a temporary GISRC, and optionally scaffolds a fresh mapset.

Type Description
GrassSession Builder — resolves GISBASE/GISDBASE/location/mapset, scaffolds mapset, spawns worker
GrassSessionHandle Owns the session; removes GISRC on drop; .client(), .shutdown_and_join()
use grass_gis::GrassSession;

#[tokio::main]
async fn main() -> grass_gis::GrassResult<()> {
    let session = GrassSession::new("my_tool")
        .from_env()                // reads GISBASE, GISDBASE, LOCATION_NAME, MAPSET
        .fresh_mapset("my_output") // wipe-and-recreate output mapset
        .spawn()?;

    let client = session.client().clone();
    // ... do work ...
    session.shutdown_and_join().await?;
    Ok(())
}

Client / Worker (low-level)

Use GrassRuntime directly when you are already inside a GRASS session (env vars set) and only need the async worker handle without session lifecycle management.

Type Description
GrassRuntime Builder — new("module").capacity(n).spawn()
GrassRuntimeHandle .client()&GrassClient, .join(), .shutdown_and_join()
GrassClient ~70 async methods for GIS, raster, vector, DB, segment, parser

High-Level Wrappers

Type Module Description
RasterReader raster Open, read rows (i32/f32/f64/native), metadata, close
RasterWriter raster Create, write rows (sequential), flush, close
RasterMetadata raster Read header, history, color table, categories in one call
RasterCategories raster Read/write category labels
VectorMapClient vector Open/close, read/write lines, build, topology, spatial finders, iterators
Feature vector Feature data — id, type, points, categories
FeatureIter vector Iterator over all features
Area / AreaIter vector Area data with boundaries, centroid, size, perimeter
BBox vector 2D/3D bounding box with containment/intersection tests
DbDriver / DbCursor db SQL execution, cursor-based fetching
AttributeRow / AttributeRows db Typed attribute access (i64, f64, str)
Segment<T> segment Generic random-access raster tiling
ParserClient parser GRASS command-line argument parsing
Region gis Region queries, apply, from_raster
Projection gis Projection info with lat/lon detection
Mapset / Location mapset Mapset/location queries, map listing
MapsetSearchPath mapset Search path management

Version Control

This project uses jj (Jujutsu) — a modern, git-compatible VCS — instead of raw git.

# View change history
jj log

# View current changes
jj diff

# Create a new change on top
jj new "description of next step"

# Squash a child change into its parent
jj squash -s <child-change-id>

License

GPL-3.0-only — matching the GRASS GIS license.

About

Safe, idiomatic Rust bindings for GRASS GIS

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors