Functional Weave
Code in Rust

geo.bounding-box

Latitude/longitude bounding box that contains every point within a distance of a centre point.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 15 tests, run in TypeScript, Python and Rust.

What it does

The smallest latitude/longitude box that contains every point within a given distance of a centre. It is the cheap first filter for "find everything within 10 km": query the box with an index, then check the real distance (`geo.distance`) on what comes back.

## Method

For example

  • bounding_box(51.507, -0.128, 10,000) → min lat 51.417, min lng -0.272, max lat 51.597, max lng 0.017 10 km around central London
  • bounding_box(0, 0, 100,000) → min lat -0.899, min lng -0.899, max lat 0.899, max lng 0.899 100 km around the origin is square in degrees on the equator
  • bounding_box(60, 10, 100,000) → min lat 59.101, min lng 8.201, max lat 60.899, max lng 11.799 at 60 degrees north the longitude span is about twice the latitude span

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn bounding_box(lat: f64, lng: f64, distance_metres: f64) -> BoundingBox
latfloatcentre latitude in degrees, -90 to 90
lngfloatcentre longitude in degrees, -180 to 180
distance_metresfloatsearch radius in metres, 0 or more
returnsBoundingBoxminLng > maxLng means the box crosses the antimeridian

The type it declares, generated into your project

/// Degrees, rounded outward to 7 decimals so the box always contains the circle.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoundingBox {
    pub min_lat: f64,
    pub min_lng: f64,
    pub max_lat: f64,
    pub max_lng: f64,
}

Your code names it in one line, in the file that uses it

fune!(geo.bounding-box@^1);  // then call bounding_box(…)
impl/rust.rs · 226 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

// The latitude/longitude box that contains every point within a distance of a
// centre, by the method of J. P. Matuschek, "Finding Points Within a Distance
// of a Latitude/Longitude Using Bounding Coordinates"
// (http://janmatuschek.de/LatitudeLongitudeBoundingCoordinates).
//
// The trigonometry is written out below instead of calling f64::sin and
// friends, because the platform maths library may differ in the last bit
// between Rust, JavaScript and Python. Only correctly-rounded IEEE 754
// operations (+ - * / sqrt floor) are used, in the same order in all three.

/// IUGG mean radius R1 of the GRS 80 ellipsoid (Moritz, Journal of Geodesy 74 (2000)).
const EARTH_RADIUS_METRES: f64 = 6371008.8;
/// Ten millionths of a degree, about a centimetre.
const SCALE: f64 = 10000000.0;
/// Within a millionth of a grid step of a 7-decimal value counts as that value,
/// so a centre of 51.5074 stays 51.5074 rather than becoming 51.5073999.
const SNAP: f64 = 0.000001;

const PI: f64 = 3.141592653589793;
const HALF_PI: f64 = PI / 2.0;
const DEGREES: f64 = PI / 180.0;

const S3: f64 = -1.0 / 6.0;
const S5: f64 = 1.0 / 120.0;
const S7: f64 = -1.0 / 5040.0;
const S9: f64 = 1.0 / 362880.0;
const S11: f64 = -1.0 / 39916800.0;
const S13: f64 = 1.0 / 6227020800.0;
const S15: f64 = -1.0 / 1307674368000.0;
const S17: f64 = 1.0 / 355687428096000.0;
const C2: f64 = -1.0 / 2.0;
const C4: f64 = 1.0 / 24.0;
const C6: f64 = -1.0 / 720.0;
const C8: f64 = 1.0 / 40320.0;
const C10: f64 = -1.0 / 3628800.0;
const C12: f64 = 1.0 / 479001600.0;
const C14: f64 = -1.0 / 87178291200.0;
const C16: f64 = 1.0 / 20922789888000.0;
const C18: f64 = -1.0 / 6402373705728000.0;
const A3: f64 = -1.0 / 3.0;
const A5: f64 = 1.0 / 5.0;
const A7: f64 = -1.0 / 7.0;
const A9: f64 = 1.0 / 9.0;
const A11: f64 = -1.0 / 11.0;
const A13: f64 = 1.0 / 13.0;
const A15: f64 = -1.0 / 15.0;
const A17: f64 = 1.0 / 17.0;
const A19: f64 = -1.0 / 19.0;
const A21: f64 = 1.0 / 21.0;

fn sin_small(r: f64) -> f64 {
    let s = r * r;
    let mut p = S17;
    p = S15 + s * p;
    p = S13 + s * p;
    p = S11 + s * p;
    p = S9 + s * p;
    p = S7 + s * p;
    p = S5 + s * p;
    p = S3 + s * p;
    r + r * s * p
}

fn cos_small(r: f64) -> f64 {
    let s = r * r;
    let mut p = C18;
    p = C16 + s * p;
    p = C14 + s * p;
    p = C12 + s * p;
    p = C10 + s * p;
    p = C8 + s * p;
    p = C6 + s * p;
    p = C4 + s * p;
    p = C2 + s * p;
    1.0 + s * p
}

fn sin_cos(x: f64) -> (f64, f64) {
    let k = (x / HALF_PI + 0.5).floor();
    let r = x - k * HALF_PI;
    let sr = sin_small(r);
    let cr = cos_small(r);
    match (k as i64).rem_euclid(4) {
        0 => (sr, cr),
        1 => (cr, -sr),
        2 => (-sr, -cr),
        _ => (-cr, sr),
    }
}

fn atan_unit(u: f64) -> f64 {
    let mut v = u;
    v = v / (1.0 + (1.0 + v * v).sqrt());
    v = v / (1.0 + (1.0 + v * v).sqrt());
    v = v / (1.0 + (1.0 + v * v).sqrt());
    let s = v * v;
    let mut p = A21;
    p = A19 + s * p;
    p = A17 + s * p;
    p = A15 + s * p;
    p = A13 + s * p;
    p = A11 + s * p;
    p = A9 + s * p;
    p = A7 + s * p;
    p = A5 + s * p;
    p = A3 + s * p;
    8.0 * (v + v * s * p)
}

/// asin for 0 <= x, via atan; (1 - x)(1 + x) keeps precision near 1.
fn asin_positive(x: f64) -> f64 {
    if x >= 1.0 {
        return HALF_PI;
    }
    let t = x / ((1.0 - x) * (1.0 + x)).sqrt();
    if t > 1.0 {
        HALF_PI - atan_unit(1.0 / t)
    } else {
        atan_unit(t)
    }
}

/// Outward rounding: a lower bound goes down and an upper bound goes up, so
/// rounding can only grow the box, never cut the circle.
fn round_down(x: f64) -> f64 {
    (x * SCALE + SNAP).floor() / SCALE + 0.0
}

fn round_up(x: f64) -> f64 {
    (x * SCALE - SNAP).ceil() / SCALE + 0.0
}

fn check_number(value: f64, what: &str) {
    if !value.is_finite() {
        panic!("{} must be a finite number", what);
    }
}

/// The box containing every point within `distance_metres` of (`lat`, `lng`).
///
/// # Panics
/// Panics if a coordinate is out of range or the distance is negative or not finite.
pub fn bounding_box(lat: f64, lng: f64, distance_metres: f64) -> BoundingBox {
    check_number(lat, "latitude");
    check_number(lng, "longitude");
    check_number(distance_metres, "distance");
    if lat < -90.0 || lat > 90.0 {
        panic!("latitude must be between -90 and 90 degrees");
    }
    if lng < -180.0 || lng > 180.0 {
        panic!("longitude must be between -180 and 180 degrees");
    }
    if distance_metres < 0.0 {
        panic!("distance must be 0 or more metres");
    }

    let r = distance_metres / EARTH_RADIUS_METRES;
    let r_degrees = r / DEGREES;
    let mut min_lat = lat - r_degrees;
    let mut max_lat = lat + r_degrees;
    let mut min_lng;
    let mut max_lng;

    if min_lat > -90.0 && max_lat < 90.0 {
        let sin_r = sin_cos(r).0;
        let cos_lat = sin_cos(lat * DEGREES).1;
        let d_lng = asin_positive(sin_r / cos_lat) / DEGREES;
        min_lng = lng - d_lng;
        max_lng = lng + d_lng;
        // Past the antimeridian the bound comes round the other side, leaving
        // min_lng > max_lng: the box is the two strips either side of 180.
        if min_lng < -180.0 {
            min_lng += 360.0;
        }
        if max_lng > 180.0 {
            max_lng -= 360.0;
        }
    } else {
        // The circle reaches a pole, so it covers every longitude.
        if min_lat < -90.0 {
            min_lat = -90.0;
        }
        if max_lat > 90.0 {
            max_lat = 90.0;
        }
        min_lng = -180.0;
        max_lng = 180.0;
    }

    BoundingBox {
        min_lat: round_down(min_lat),
        min_lng: round_down(min_lng),
        max_lat: round_up(max_lat),
        max_lng: round_up(max_lng),
    }
}

pub fn bounding_box_to_value(b: &BoundingBox) -> Value {
    Value::obj(vec![
        ("minLat", Value::Float(b.min_lat)),
        ("minLng", Value::Float(b.min_lng)),
        ("maxLat", Value::Float(b.max_lat)),
        ("maxLng", Value::Float(b.max_lng)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion below quietly change it.
    if !matches!(args[0], Value::Int(_) | Value::Float(_)) {
        panic!("latitude must be a finite number");
    }
    if !matches!(args[1], Value::Int(_) | Value::Float(_)) {
        panic!("longitude must be a finite number");
    }
    if !matches!(args[2], Value::Int(_) | Value::Float(_)) {
        panic!("distance must be a finite number");
    }
    bounding_box_to_value(&bounding_box(
        args[0].as_f64(),
        args[1].as_f64(),
        args[2].as_f64(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add geo.bounding-box
Download for Rust geo.bounding-box-1.0.0-rust.fune · 14,329 bytes sha256 2eaaf1d8d2d46e67ef0fac7c9f6ab82ec7ef8b9bd13cc1de4f6e25f4c6c5ef10

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./geo.bounding-box-1.0.0-rust.fune, or fetch it from a terminal with fune pull geo.bounding-box@1.0.0:rust.

The whole function, every language, is one file too: geo.bounding-box-1.0.0.fune, 25,515 bytes, sha256 74658290375ebdd1c98f319e6acc3f3438a4e0b2156f09909818f7c1c02ba2c9. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before geo.bounding-box

after — your function gets the result and the arguments, and returns the final result.

// fune: after geo.bounding-box

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show geo.bounding-box --steps.

// fune: step geo.bounding-box after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
10 km around central London 51.507, -0.128, 10,000 → min lat 51.417, min lng -0.272, max lat 51.597, max lng 0.017
100 km around the origin is square in degrees on the equator 0, 0, 100,000 → min lat -0.899, min lng -0.899, max lat 0.899, max lng 0.899
at 60 degrees north the longitude span is about twice the latitude span 60, 10, 100,000 → min lat 59.101, min lng 8.201, max lat 60.899, max lng 11.799
southern and eastern hemispheres -33.869, 151.209, 5,000 → min lat -33.914, min lng 151.155, max lat -33.824, max lng 151.263
zero distance is the point itself, not nudged a grid step outward by its binary value 51.507, -0.128, 0 → min lat 51.507, min lng -0.128, max lat 51.507, max lng -0.128
crossing the antimeridian wraps maxLng round, so minLng > maxLng 0, 179.9, 50,000 → min lat -0.45, min lng 179.45, max lat 0.45, max lng -179.65
a centre on the antimeridian wraps minLng round 45, -180, 1,000 → min lat 44.991, min lng 179.987, max lat 45.009, max lng -179.987
a circle over the north pole covers every longitude (the asin formula alone gives NaN here) 89.9, 0, 20,000 → min lat 89.72, min lng -180, max lat 90, max lng 180
a circle over the south pole covers every longitude -89.95, 45, 10,000 → min lat -90, min lng -180, max lat -89.86, max lng 180
just short of the pole the longitude span is very wide but not global 80, 0, 1,100,000 → min lat 70.107, min lng -81.634, max lat 89.893, max lng 81.634
Show the other 5 tests
CaseArgumentsExpected
a radius larger than the Earth is the whole globe 0, 0, 30,000,000 → min lat -90, min lng -180, max lat 90, max lng 180
negative distance is an error 51.5, 0, -1 → error: distance must be 0 or more metres
latitude out of range is an error 95, 0, 1,000 → error: latitude must be between -90 and 90 degrees
longitude out of range is an error 0, -181, 1,000 → error: longitude must be between -180 and 180 degrees
a distance that is not a number is an error 0, 0, 10km → error: distance must be a finite number

More from the author

J. P. Matuschek, "Finding Points Within a Distance of a Latitude/Longitude Using Bounding Coordinates", http://janmatuschek.de/LatitudeLongitudeBoundingCoordinates. The Earth is a sphere of radius 6,371,008.8 m (IUGG mean radius, as in `geo.distance`). The angular radius is r = d / R; latitudes are centre ± r; the longitude half-width is asin(sin r / cos lat), which is wider than r away from the equator (about twice as wide at 60°).

## Edge cases

- **Poles.** If the circle reaches or crosses a pole, it covers every longitude: the box is clamped to ±90 and runs from -180 to 180. Without this check the asin formula is asked for asin of more than 1 and returns NaN. - **The antimeridian.** A bound that passes ±180 comes round the other side, so minLng > maxLng. That is the signal that the box is two strips, minLng..180 and -180..maxLng; a query must OR them rather than use BETWEEN. - **Zero distance** is the point itself. **Negative distance** is an error. - A radius larger than the Earth gives the whole globe.

## Rounding outward

Bounds are rounded to 7 decimal places (about 1 cm), outward: the minimums down and the maximums up, so rounding can only grow the box and never cut off part of the circle. A value within a millionth of a step of a 7-decimal value (about 1e-13 degrees) is taken to be that value, so a centre of 51.5074 with distance 0 stays 51.5074 rather than becoming 51.5073999 because its binary value sits a hair below.

## Why the answers are identical in every language

As in `geo.distance`, the trigonometry is written out using only correctly rounded IEEE 754 operations (+ − × ÷, sqrt, floor) in the same order in all three languages, instead of calling the platform's Math.sin, which may differ in the last bit and could tip a value across a rounding step. On 4,000 random inputs the unrounded and rounded bounds were bit-for-bit identical in TypeScript, Python and Rust.

Files

PathBytes
README.md2,222
impl/python.py5,186
impl/rust.rs6,756
impl/typescript.ts5,605
vectors.json2,743