HomeDocumentationAdvanced Flutter and C++ in Dart FFI
Advanced Flutter and C++ in Dart FFI
16

Geospatial Processing With PROJ and GEOS in Flutter

Flutter PROJ GEOS FFI — Coordinate Transforms and Spatial Queries on Device

April 24, 2026

You're building a field survey app. Or a delivery routing tool. Or an agriculture app where a farmer draws a polygon on a map and needs to know the area in hectares — not screen pixels. The GPS gives you coordinates in WGS84 (latitude/longitude). The government land registry uses a national grid projection. The area calculation needs to happen in meters, not degrees. And all of this needs to work offline, in the middle of a field with no cell signal.

Two C libraries handle this:

  • PROJ — coordinate reference system transforms. GPS coordinates (EPSG:4326) to UTM (EPSG:32635) to the Romanian national grid (EPSG:3844) to whatever your data source uses. It's the engine behind QGIS, PostGIS, GDAL, and every serious GIS tool.
  • GEOS — spatial operations. "Is this point inside this polygon?" "What's the area of this polygon?" "Where do these two regions overlap?" "Buffer this line by 50 meters." It's the C/C++ port of JTS (Java Topology Suite), the geometry engine behind PostGIS's spatial functions.

Together, they give your Flutter app the same geospatial capabilities as a desktop GIS application.

Getting PROJ

PROJ depends on SQLite (for its coordinate database) and libtiff (optional, for grid shift files). The core library is what we need.

Building from source

bash
git clone https://github.com/OSGeo/PROJ.git
cd PROJ
mkdir build && cd build

# For host (testing)
cmake -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_TESTING=OFF \
  -DBUILD_APPS=OFF ..
make

# For Android arm64
cmake -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_NATIVE_API_LEVEL=24 \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_TESTING=OFF \
  -DBUILD_APPS=OFF ..
make

This produces libproj.so (~2MB). Larger than the other libraries in this series because PROJ includes its coordinate database.

Important: PROJ needs its data files (proj.db and grid shift files) at runtime. On Android, bundle proj.db in assets and copy it to the app's files directory. Set the search path before using PROJ:

c
proj_context_set_search_paths(ctx, 1, (const char*[]){ "/data/data/com.yourapp/files/proj" });

iOS

Build as a framework or use CocoaPods. The same CMake build works with the iOS toolchain. Bundle proj.db in the app bundle.

Getting GEOS

GEOS is simpler — no external data files, no database dependency.

bash
git clone https://github.com/libgeos/geos.git
cd geos
mkdir build && cd build

cmake -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_TESTING=OFF ..
make

# For Android arm64
cmake -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_NATIVE_API_LEVEL=24 \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_TESTING=OFF ..
make

This produces libgeos_c.so (~1MB). Note: use the C API (geos_c.h), not the C++ API. The C API is stable across versions; the C++ API is not.

The C bridge

We'll wrap both libraries in a single bridge that handles the most common operations: coordinate transforms, point-in-polygon, area calculation, buffering, and intersection.

c
// native/src/geo_bridge.c
#include <proj.h>
#include <geos_c.h>
#include <stdlib.h>
#include <string.h>

// ============ PROJ: Coordinate Transforms ============

static PJ_CONTEXT* proj_ctx = NULL;

int32_t geo_proj_init(const char* proj_db_path) {
    proj_ctx = proj_context_create();
    if (proj_ctx == NULL) return -1;

    if (proj_db_path != NULL) {
        const char* paths[] = { proj_db_path };
        proj_context_set_search_paths(proj_ctx, 1, paths);
    }

    return 0;
}

// Transform coordinates between CRS.
// src_crs and dst_crs are EPSG codes like "EPSG:4326"
int32_t geo_proj_transform(
    const char* src_crs,
    const char* dst_crs,
    double* x,          // longitude or easting (in/out)
    double* y,          // latitude or northing (in/out)
    int32_t count       // number of coordinate pairs
) {
    PJ* transform = proj_create_crs_to_crs(proj_ctx, src_crs, dst_crs, NULL);
    if (transform == NULL) return -1;

    // PROJ expects lon/lat in radians for some CRS, but
    // proj_create_crs_to_crs handles degree/radian conversion.
    // However, we need to normalize the axis order.
    PJ* norm = proj_normalize_for_visualization(proj_ctx, transform);
    proj_destroy(transform);
    if (norm == NULL) return -2;

    for (int i = 0; i < count; i++) {
        PJ_COORD input = proj_coord(x[i], y[i], 0, 0);
        PJ_COORD output = proj_trans(norm, PJ_FWD, input);

        if (output.xy.x == HUGE_VAL) {
            proj_destroy(norm);
            return -(100 + i); // Error at coordinate index i
        }

        x[i] = output.xy.x;
        y[i] = output.xy.y;
    }

    proj_destroy(norm);
    return 0;
}

void geo_proj_cleanup(void) {
    if (proj_ctx != NULL) {
        proj_context_destroy(proj_ctx);
        proj_ctx = NULL;
    }
}

// ============ GEOS: Spatial Operations ============

static GEOSContextHandle_t geos_ctx = NULL;

// Error handler — GEOS calls this on errors
static void geos_error_handler(const char* fmt, ...) {
    // In production, log this somewhere Dart can read it
}

int32_t geo_geos_init(void) {
    geos_ctx = GEOS_init_r();
    if (geos_ctx == NULL) return -1;
    GEOSContext_setErrorHandler_r(geos_ctx, geos_error_handler);
    return 0;
}

// Create a polygon from an array of coordinate pairs.
// coords: [x0, y0, x1, y1, ..., xN, yN]
// The ring is automatically closed (first point = last point).
GEOSGeometry* geo_create_polygon(
    const double* coords,
    int32_t num_points
) {
    // Create coordinate sequence (num_points + 1 for closing)
    GEOSCoordSequence* seq = GEOSCoordSeq_create_r(
        geos_ctx, num_points + 1, 2
    );

    for (int i = 0; i < num_points; i++) {
        GEOSCoordSeq_setX_r(geos_ctx, seq, i, coords[i * 2]);
        GEOSCoordSeq_setY_r(geos_ctx, seq, i, coords[i * 2 + 1]);
    }
    // Close the ring
    GEOSCoordSeq_setX_r(geos_ctx, seq, num_points, coords[0]);
    GEOSCoordSeq_setY_r(geos_ctx, seq, num_points, coords[1]);

    GEOSGeometry* ring = GEOSGeom_createLinearRing_r(geos_ctx, seq);
    if (ring == NULL) return NULL;

    GEOSGeometry* polygon = GEOSGeom_createPolygon_r(
        geos_ctx, ring, NULL, 0
    );
    return polygon;
}

// Point-in-polygon test.
// Returns 1 if inside, 0 if outside, -1 on error.
int32_t geo_point_in_polygon(
    double px, double py,
    const double* polygon_coords,
    int32_t num_polygon_points
) {
    GEOSGeometry* polygon = geo_create_polygon(
        polygon_coords, num_polygon_points
    );
    if (polygon == NULL) return -1;

    GEOSCoordSequence* pt_seq = GEOSCoordSeq_create_r(geos_ctx, 1, 2);
    GEOSCoordSeq_setX_r(geos_ctx, pt_seq, 0, px);
    GEOSCoordSeq_setY_r(geos_ctx, pt_seq, 0, py);
    GEOSGeometry* point = GEOSGeom_createPoint_r(geos_ctx, pt_seq);

    char result = GEOSContains_r(geos_ctx, polygon, point);

    GEOSGeom_destroy_r(geos_ctx, point);
    GEOSGeom_destroy_r(geos_ctx, polygon);

    return (int32_t)result;
}

// Calculate the area of a polygon.
// coords must be in a projected CRS (meters), not lat/lon.
double geo_polygon_area(
    const double* coords,
    int32_t num_points
) {
    GEOSGeometry* polygon = geo_create_polygon(coords, num_points);
    if (polygon == NULL) return -1.0;

    double area;
    int result = GEOSArea_r(geos_ctx, polygon, &area);
    GEOSGeom_destroy_r(geos_ctx, polygon);

    return result == 1 ? area : -1.0;
}

// Buffer a polygon by a distance (in CRS units — meters if projected).
// Returns the buffered polygon as WKT, or NULL on error.
// Caller must free the returned string with geo_free().
char* geo_buffer_polygon(
    const double* coords,
    int32_t num_points,
    double distance,
    int32_t quad_segments  // 8 is a good default
) {
    GEOSGeometry* polygon = geo_create_polygon(coords, num_points);
    if (polygon == NULL) return NULL;

    GEOSGeometry* buffered = GEOSBuffer_r(
        geos_ctx, polygon, distance, quad_segments
    );
    GEOSGeom_destroy_r(geos_ctx, polygon);
    if (buffered == NULL) return NULL;

    GEOSWKTWriter* writer = GEOSWKTWriter_create_r(geos_ctx);
    char* wkt = GEOSWKTWriter_write_r(geos_ctx, writer, buffered);
    GEOSWKTWriter_destroy_r(geos_ctx, writer);
    GEOSGeom_destroy_r(geos_ctx, buffered);

    // Copy to our own malloc so Dart can free it
    char* result = malloc(strlen(wkt) + 1);
    strcpy(result, wkt);
    GEOSFree_r(geos_ctx, wkt);

    return result;
}

// Check if two polygons intersect.
// Returns 1 if they intersect, 0 if not, -1 on error.
int32_t geo_polygons_intersect(
    const double* coords_a, int32_t num_points_a,
    const double* coords_b, int32_t num_points_b
) {
    GEOSGeometry* a = geo_create_polygon(coords_a, num_points_a);
    GEOSGeometry* b = geo_create_polygon(coords_b, num_points_b);
    if (a == NULL || b == NULL) {
        if (a) GEOSGeom_destroy_r(geos_ctx, a);
        if (b) GEOSGeom_destroy_r(geos_ctx, b);
        return -1;
    }

    char result = GEOSIntersects_r(geos_ctx, a, b);

    GEOSGeom_destroy_r(geos_ctx, a);
    GEOSGeom_destroy_r(geos_ctx, b);

    return (int32_t)result;
}

void geo_free(void* ptr) {
    free(ptr);
}

void geo_geos_cleanup(void) {
    if (geos_ctx != NULL) {
        GEOS_finish_r(geos_ctx);
        geos_ctx = NULL;
    }
}

Dart FFI bindings

dart
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';

final DynamicLibrary _lib = Platform.isAndroid
    ? DynamicLibrary.open('libgeo_bridge.so')
    : DynamicLibrary.process();

// ============ PROJ ============

final _projInit = _lib.lookupFunction<
    Int32 Function(Pointer<Utf8>),
    int Function(Pointer<Utf8>)
>('geo_proj_init');

final _projTransform = _lib.lookupFunction<
    Int32 Function(Pointer<Utf8>, Pointer<Utf8>,
                   Pointer<Double>, Pointer<Double>, Int32),
    int Function(Pointer<Utf8>, Pointer<Utf8>,
                 Pointer<Double>, Pointer<Double>, int)
>('geo_proj_transform');

final _projCleanup = _lib.lookupFunction<
    Void Function(), void Function()
>('geo_proj_cleanup');

// ============ GEOS ============

final _geosInit = _lib.lookupFunction<
    Int32 Function(), int Function()
>('geo_geos_init');

final _pointInPolygon = _lib.lookupFunction<
    Int32 Function(Double, Double, Pointer<Double>, Int32),
    int Function(double, double, Pointer<Double>, int)
>('geo_point_in_polygon');

final _polygonArea = _lib.lookupFunction<
    Double Function(Pointer<Double>, Int32),
    double Function(Pointer<Double>, int)
>('geo_polygon_area');

final _bufferPolygon = _lib.lookupFunction<
    Pointer<Utf8> Function(Pointer<Double>, Int32, Double, Int32),
    Pointer<Utf8> Function(Pointer<Double>, int, double, int)
>('geo_buffer_polygon');

final _polygonsIntersect = _lib.lookupFunction<
    Int32 Function(Pointer<Double>, Int32, Pointer<Double>, Int32),
    int Function(Pointer<Double>, int, Pointer<Double>, int)
>('geo_polygons_intersect');

final _geoFree = _lib.lookupFunction<
    Void Function(Pointer<Void>),
    void Function(Pointer<Void>)
>('geo_free');

final _geosCleanup = _lib.lookupFunction<
    Void Function(), void Function()
>('geo_geos_cleanup');

Clean Dart wrappers

dart
/// A geographic coordinate (longitude, latitude) or projected coordinate
/// (easting, northing).
class GeoPoint {
  final double x; // longitude or easting
  final double y; // latitude or northing

  const GeoPoint(this.x, this.y);

  @override
  String toString() => 'GeoPoint($x, $y)';
}

/// PROJ coordinate transforms.
class ProjTransformer {
  static bool _initialized = false;

  /// Initialize PROJ. [projDbPath] is the directory containing proj.db.
  static void init({String? projDbPath}) {
    if (_initialized) return;

    final pathPtr = projDbPath?.toNativeUtf8() ?? nullptr.cast<Utf8>();
    try {
      final result = _projInit(pathPtr);
      if (result != 0) throw Exception('PROJ init failed: $result');
      _initialized = true;
    } finally {
      if (projDbPath != null) calloc.free(pathPtr);
    }
  }

  /// Transform coordinates from one CRS to another.
  /// CRS identifiers are EPSG codes like "EPSG:4326" or "EPSG:32635".
  static List<GeoPoint> transform(
    String fromCrs,
    String toCrs,
    List<GeoPoint> points,
  ) {
    final fromPtr = fromCrs.toNativeUtf8();
    final toPtr = toCrs.toNativeUtf8();
    final xPtr = calloc<Double>(points.length);
    final yPtr = calloc<Double>(points.length);

    try {
      for (int i = 0; i < points.length; i++) {
        xPtr[i] = points[i].x;
        yPtr[i] = points[i].y;
      }

      final result = _projTransform(
        fromPtr, toPtr, xPtr, yPtr, points.length,
      );

      if (result != 0) {
        throw Exception('PROJ transform failed: $result');
      }

      return List.generate(
        points.length,
        (i) => GeoPoint(xPtr[i], yPtr[i]),
      );
    } finally {
      calloc.free(fromPtr);
      calloc.free(toPtr);
      calloc.free(xPtr);
      calloc.free(yPtr);
    }
  }

  static void dispose() {
    _projCleanup();
    _initialized = false;
  }
}

/// GEOS spatial operations.
class SpatialOps {
  static bool _initialized = false;

  static void init() {
    if (_initialized) return;
    final result = _geosInit();
    if (result != 0) throw Exception('GEOS init failed: $result');
    _initialized = true;
  }

  /// Allocate a native double array from a list of GeoPoints and return
  /// the pointer. Caller must free.
  static Pointer<Double> _pointsToNative(List<GeoPoint> points) {
    final ptr = calloc<Double>(points.length * 2);
    for (int i = 0; i < points.length; i++) {
      ptr[i * 2] = points[i].x;
      ptr[i * 2 + 1] = points[i].y;
    }
    return ptr;
  }

  /// Test if a point is inside a polygon.
  static bool pointInPolygon(GeoPoint point, List<GeoPoint> polygon) {
    final polyPtr = _pointsToNative(polygon);
    try {
      final result = _pointInPolygon(
        point.x, point.y, polyPtr, polygon.length,
      );
      if (result < 0) throw Exception('Point-in-polygon failed');
      return result == 1;
    } finally {
      calloc.free(polyPtr);
    }
  }

  /// Calculate the area of a polygon.
  /// The polygon coordinates MUST be in a projected CRS (meters)
  /// for the result to be meaningful. Returns area in square meters.
  static double polygonArea(List<GeoPoint> polygon) {
    final polyPtr = _pointsToNative(polygon);
    try {
      final area = _polygonArea(polyPtr, polygon.length);
      if (area < 0) throw Exception('Area calculation failed');
      return area;
    } finally {
      calloc.free(polyPtr);
    }
  }

  /// Buffer a polygon by a distance.
  /// Distance is in CRS units (meters if using a projected CRS).
  /// Returns the buffered polygon as WKT.
  static String bufferPolygon(
    List<GeoPoint> polygon,
    double distance, {
    int quadSegments = 8,
  }) {
    final polyPtr = _pointsToNative(polygon);
    try {
      final wktPtr = _bufferPolygon(
        polyPtr, polygon.length, distance, quadSegments,
      );
      if (wktPtr == nullptr) throw Exception('Buffer operation failed');

      final wkt = wktPtr.toDartString();
      _geoFree(wktPtr.cast());
      return wkt;
    } finally {
      calloc.free(polyPtr);
    }
  }

  /// Test if two polygons intersect.
  static bool polygonsIntersect(
    List<GeoPoint> polygonA,
    List<GeoPoint> polygonB,
  ) {
    final ptrA = _pointsToNative(polygonA);
    final ptrB = _pointsToNative(polygonB);
    try {
      final result = _polygonsIntersect(
        ptrA, polygonA.length, ptrB, polygonB.length,
      );
      if (result < 0) throw Exception('Intersection test failed');
      return result == 1;
    } finally {
      calloc.free(ptrA);
      calloc.free(ptrB);
    }
  }

  static void dispose() {
    _geosCleanup();
    _initialized = false;
  }
}

Real-world use case: field area calculator

The farmer draws a polygon on the map. The map gives you WGS84 coordinates (EPSG:4326). You need the area in hectares. Here's the complete flow:

dart
class FieldAreaCalculator {
  /// Calculate the area of a field defined by GPS coordinates.
  /// Returns area in hectares.
  static double calculateHectares(List<GeoPoint> gpsCoordinates) {
    // Step 1: Determine the appropriate UTM zone from the centroid
    final centroidLon = gpsCoordinates
        .map((p) => p.x)
        .reduce((a, b) => a + b) / gpsCoordinates.length;
    final utmZone = ((centroidLon + 180) / 6).floor() + 1;

    // For Romania, UTM zone 35N (EPSG:32635) covers most of the country.
    // For general use, compute the zone dynamically.
    final utmEpsg = 'EPSG:326$utmZone'; // Northern hemisphere
    // For southern hemisphere: 'EPSG:327$utmZone'

    // Step 2: Transform GPS coordinates to UTM (meters)
    final projected = ProjTransformer.transform(
      'EPSG:4326', utmEpsg, gpsCoordinates,
    );

    // Step 3: Calculate area in square meters using GEOS
    final areaSqMeters = SpatialOps.polygonArea(projected);

    // Step 4: Convert to hectares (1 hectare = 10,000 sq meters)
    return areaSqMeters / 10000.0;
  }
}

// Usage
void onFieldDrawn(List<LatLng> mapPoints) {
  final gpsPoints = mapPoints
      .map((p) => GeoPoint(p.longitude, p.latitude))
      .toList();

  final hectares = FieldAreaCalculator.calculateHectares(gpsPoints);
  print('Field area: ${hectares.toStringAsFixed(2)} hectares');
  // "Field area: 12.47 hectares"
}

Geofencing (point-in-polygon)

dart
class GeofenceService {
  final List<List<GeoPoint>> _zones;

  GeofenceService(this._zones);

  /// Check which zones contain the given GPS position.
  List<int> getContainingZones(double longitude, double latitude) {
    final point = GeoPoint(longitude, latitude);
    final results = <int>[];

    for (int i = 0; i < _zones.length; i++) {
      if (SpatialOps.pointInPolygon(point, _zones[i])) {
        results.add(i);
      }
    }

    return results;
  }
}

Buffer zone around infrastructure

dart
// "Show me everything within 500 meters of this pipeline"
final pipelineRoute = [
  GeoPoint(438210.5, 5027340.2), // Already in UTM meters
  GeoPoint(438450.1, 5027520.8),
  GeoPoint(438710.3, 5027410.5),
  // ... more points
];

// Buffer by 500 meters, get back a polygon that represents
// the 500m zone around the pipeline
final bufferWkt = SpatialOps.bufferPolygon(pipelineRoute, 500.0);
// Returns WKT like "POLYGON((438210.5 5026840.2, ...))"

// Parse WKT and display on map, or test if parcels intersect

Bundling proj.db for Android

PROJ needs its database file at runtime. Here's how to handle it:

dart
Future<String> _initProjDatabase() async {
  final dir = await getApplicationSupportDirectory();
  final projDir = Directory('${dir.path}/proj');
  final dbFile = File('${projDir.path}/proj.db');

  if (!await dbFile.exists()) {
    await projDir.create(recursive: true);
    final data = await rootBundle.load('assets/proj/proj.db');
    await dbFile.writeAsBytes(data.buffer.asUint8List());
  }

  return projDir.path;
}

// At app startup
Future<void> initGeospatial() async {
  final projDbPath = await _initProjDatabase();
  ProjTransformer.init(projDbPath: projDbPath);
  SpatialOps.init();
}

proj.db is about 4MB. Include it in your Flutter assets:

yaml
# pubspec.yaml
flutter:
  assets:
    - assets/proj/proj.db

For iOS, the same approach works — copy from the app bundle to a writable directory.

Common errors

PROJ transform returns HUGE_VAL — unknown CRS

Cause: PROJ can't find the coordinate reference system definition. This usually means proj.db isn't in the search path, or the EPSG code is wrong.

Fix: Verify the search path is set correctly before any transform. Check that proj.db exists at the expected path:

dart
final dbFile = File('${projDir.path}/proj.db');
if (!await dbFile.exists()) {
  throw Exception('proj.db not found at ${dbFile.path}');
}

Also verify your EPSG code is valid. "EPSG:4326" is WGS84 (GPS). "EPSG:32635" is UTM zone 35N. The full list is at epsg.io.

Area calculation returns nonsense (trillions of square meters)

Cause: You calculated the area using lat/lon coordinates (EPSG:4326) instead of projected coordinates. In EPSG:4326, the units are degrees. One degree of longitude is ~111km at the equator but ~70km at 50°N. GEOS doesn't know about spherical geometry — it treats coordinates as planar. So GEOSArea on lat/lon coordinates gives you "square degrees," which is meaningless.

Fix: Always transform to a projected CRS (UTM, national grid) before area calculations. The area result is in the CRS's linear units squared — for UTM, that's square meters.

Point-in-polygon gives wrong results near the antimeridian

Cause: If your polygon crosses the 180° meridian (antimeridian), the coordinates jump from 179.9 to -179.9. GEOS interprets this as a polygon that wraps the wrong way around the globe.

Fix: For polygons near the antimeridian, either:

  • Split the polygon at the antimeridian into two polygons
  • Shift coordinates to avoid the wraparound (e.g., use 0-360 range instead of -180 to 180)
  • Use a projected CRS that doesn't have this discontinuity

This is a rare edge case unless you're working in the Pacific.

GEOS crashes on self-intersecting polygons

Cause: GEOS operations like GEOSArea and GEOSBuffer expect valid geometries. A self-intersecting polygon (where edges cross each other) is invalid and can cause crashes or wrong results.

Fix: Validate the geometry first:

c
char valid = GEOSisValid_r(geos_ctx, polygon);
if (!valid) {
    // Try to fix it
    GEOSGeometry* fixed = GEOSMakeValid_r(geos_ctx, polygon);
    // Use fixed instead of polygon
}

GEOSMakeValid (available in GEOS 3.8+) can fix most invalid geometries automatically. This is common with user-drawn polygons — people draw figure-eights without realizing.

"Missing grid" warning — transforms lose accuracy

Cause: PROJ can do approximate transforms without grid shift files, but high-accuracy transforms between certain CRS pairs require .tif grid files. For example, transforming between WGS84 and the Romanian national grid (Stereo 70 / EPSG:3844) is more accurate with the RO_ETRS89_ETRF2000.tif grid.

Fix: For most mobile use cases, the gridless transform is accurate enough (within 1-2 meters). If you need centimeter accuracy:

  1. Download the required grid files from PROJ's CDN
  2. Bundle them alongside proj.db
  3. PROJ will find and use them automatically from the search path

Grid files can be large (10-50MB each). Only bundle the ones you need.

Memory leak from GEOS geometries not destroyed

Cause: Every GEOSGeom_create* call allocates memory that must be freed with GEOSGeom_destroy_r. The bridge code above handles this, but if you add new operations, every geometry must be destroyed.

Fix: Follow the pattern: create geometry, use it, destroy it. Always in a try/finally:

c
GEOSGeometry* geom = geo_create_polygon(coords, count);
if (geom == NULL) return -1;
// ... use geom ...
GEOSGeom_destroy_r(geos_ctx, geom);

In the Dart wrapper, all native allocations should be freed in finally blocks.

Coordinate axis order confusion

Cause: This is the single most confusing thing in geospatial programming. EPSG:4326 officially defines coordinates as (latitude, longitude) — yes, Y before X. But most software (including Google Maps, Leaflet, and most GIS tools) uses (longitude, latitude) — X before Y. PROJ follows the official axis order by default.

Fix: Use proj_normalize_for_visualization, as shown in the bridge code above. This switches to the "GIS-friendly" order where X = longitude, Y = latitude. Without it, your coordinates will be silently swapped, and your results will be somewhere in the ocean.

Performance notes

For typical mobile use cases (transform a few hundred points, calculate area of a polygon with <1000 vertices), both PROJ and GEOS run in under 1ms. You won't need an isolate.

For bulk operations (transforming millions of points from a GeoJSON file, or testing thousands of points against complex polygons), batch the work:

dart
// Bad: one FFI call per point
for (final point in points) {
  final result = ProjTransformer.transform('EPSG:4326', 'EPSG:32635', [point]);
}

// Good: one FFI call for all points
final results = ProjTransformer.transform('EPSG:4326', 'EPSG:32635', points);

The bridge already supports batch transforms for exactly this reason.

This is Post 20 of the FFI series. This concludes the series — you now have end-to-end integration guides for ten essential C libraries in Flutter. For the fundamentals (pointers, strings, memory, structs, callbacks, isolates), see the Dart FFI Foundations series.

Related Topics

flutter geospatial ffiproj flutter coordinate transformgeos flutter spatial queryflutter offline maps processingflutter gis ffiproj dart ffigeos dart spatial operationsflutter coordinate system conversion
Flutter & Node.js

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.

Clean Architecture on every tier
iOS + Android, source code included
From $4,900 — no monthly lock-in