geo.point-in-polygon
Whether a latitude/longitude point lies inside a polygon (ray casting; the boundary counts as inside).
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
What it does
Whether a point lies inside a polygon: geofences, delivery zones, "is this address in the congestion charge area".
## Rules
For example
point_in_polygon(lat 5, lng 5, polygon ×4)→ true a point in the middle of a squarepoint_in_polygon(lat 5, lng 15, polygon ×4)→ false a point east of the squarepoint_in_polygon(lat 0, lng 5, polygon ×4)→ true a point on the bottom edge counts as inside
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 point_in_polygon(point: &GeoPoint, polygon: &[GeoPoint]) -> bool
| point | GeoPoint | the location to test |
| polygon | GeoPoint[] | the ring's vertices in order; closing the ring (last = first) is optional |
| returns | bool |
The type it declares, generated into your project
/// A position in degrees.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GeoPoint {
/// latitude, -90 to 90
pub lat: f64,
/// longitude, -180 to 180
pub lng: f64,
}
Your code names it in one line, in the file that uses it
fune!(geo.point-in-polygon@^1); // then call point_in_polygon(…)
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
/// Whether `point` lies inside `polygon`, by ray casting on the lng/lat plane.
///
/// A point exactly on an edge or a vertex counts as inside: a delivery address
/// on the boundary line of a zone is in the zone. That is checked explicitly
/// first, because plain ray casting answers boundary points inconsistently.
///
/// Only + - * / and comparisons are used, in the same order in every language,
/// so the answer is identical in Rust, TypeScript and Python even where
/// floating point rounding decides it.
///
/// # Panics
/// Panics if a coordinate is out of range or the polygon has fewer than 3
/// distinct vertices.
pub fn point_in_polygon(point: &GeoPoint, polygon: &[GeoPoint]) -> bool {
check_point(point);
for vertex in polygon {
check_point(vertex);
}
let mut ring: &[GeoPoint] = polygon;
// A closed ring repeats its first vertex at the end; that is not an edge.
if ring.len() >= 2 && same(&ring[0], &ring[ring.len() - 1]) {
ring = &ring[..ring.len() - 1];
}
if count_distinct(ring) < 3 {
panic!("a polygon needs at least 3 distinct vertices");
}
let n = ring.len();
for i in 0..n {
if on_segment(point, &ring[i], &ring[(i + 1) % n]) {
return true;
}
}
let mut inside = false;
for i in 0..n {
let a = &ring[i];
let b = &ring[(i + 1) % n];
// Half-open test so a ray passing exactly through a vertex counts it once.
if (a.lat > point.lat) != (b.lat > point.lat) {
let cross_lng = a.lng + ((point.lat - a.lat) * (b.lng - a.lng)) / (b.lat - a.lat);
if point.lng < cross_lng {
inside = !inside;
}
}
}
inside
}
fn on_segment(p: &GeoPoint, a: &GeoPoint, b: &GeoPoint) -> bool {
let cross = (b.lng - a.lng) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lng - a.lng);
if cross != 0.0 {
return false;
}
p.lng >= a.lng.min(b.lng)
&& p.lng <= a.lng.max(b.lng)
&& p.lat >= a.lat.min(b.lat)
&& p.lat <= a.lat.max(b.lat)
}
fn same(a: &GeoPoint, b: &GeoPoint) -> bool {
a.lat == b.lat && a.lng == b.lng
}
fn count_distinct(ring: &[GeoPoint]) -> usize {
let mut count = 0;
for i in 0..ring.len() {
if !(0..i).any(|j| same(&ring[i], &ring[j])) {
count += 1;
}
}
count
}
fn check_point(p: &GeoPoint) {
if !p.lat.is_finite() {
panic!("latitude must be a finite number of degrees");
}
if !p.lng.is_finite() {
panic!("longitude must be a finite number of degrees");
}
if p.lat < -90.0 || p.lat > 90.0 {
panic!("latitude must be between -90 and 90 degrees");
}
if p.lng < -180.0 || p.lng > 180.0 {
panic!("longitude must be between -180 and 180 degrees");
}
}
pub fn geo_point_from_value(v: &Value) -> GeoPoint {
GeoPoint {
lat: v.get("lat").as_f64(),
lng: v.get("lng").as_f64(),
}
}
pub fn geo_point_to_value(p: &GeoPoint) -> Value {
Value::obj(vec![("lat", Value::Float(p.lat)), ("lng", Value::Float(p.lng))])
}
pub fn fune_vector(args: &[Value]) -> Value {
let polygon: Vec<GeoPoint> = args[1].as_arr().iter().map(geo_point_from_value).collect();
Value::Bool(point_in_polygon(&geo_point_from_value(&args[0]), &polygon))
}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.point-in-polygon
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./geo.point-in-polygon-1.0.0-rust.fune, or fetch it from a terminal with fune pull geo.point-in-polygon@1.0.0:rust.
The whole function, every language, is one file too: geo.point-in-polygon-1.0.0.fune, 19,392 bytes, sha256 200241894c1b058f6c2763c8b07b0d3e658dbbd10c6a84dfbb9cf4027c03a4f8. 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.point-in-polygon
after — your function gets the result and the arguments, and returns the final result.
// fune: after geo.point-in-polygon
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.point-in-polygon --steps.
// fune: step geo.point-in-polygon 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| a point in the middle of a square | lat 5, lng 5, polygon ×4 | → | true |
| a point east of the square | lat 5, lng 15, polygon ×4 | → | false |
| a point on the bottom edge counts as inside | lat 0, lng 5, polygon ×4 | → | true |
| a point on the east edge counts as inside (plain ray casting says outside here) | lat 5, lng 10, polygon ×4 | → | true |
| a vertex counts as inside | lat 10, lng 10, polygon ×4 | → | true |
| on the line extending an edge but beyond the corner is outside | lat 0, lng 15, polygon ×4 | → | false |
| a closed ring (last vertex repeats the first) gives the same answer | lat 5, lng 5, polygon ×5 | → | true |
| clockwise and anticlockwise rings agree | lat 5, lng 5, polygon ×4 | → | true |
| concave U shape: a point in the notch is outside | lat 5, lng 5, polygon ×8 | → | false |
| concave U shape: a point in the left arm is inside | lat 5, lng 1, polygon ×8 | → | true |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| concave U shape: a point in the base under the notch is inside | lat 2, lng 5, polygon ×8 | → | true |
| concave U shape: the inner corner of the notch is on the boundary | lat 3, lng 5, polygon ×8 | → | true |
| a ray through a vertex of a diamond counts the crossing once: inside | lat 5, lng 2, polygon ×4 | → | true |
| a ray through a vertex of a diamond counts the crossing once: outside | lat 5, lng -1, polygon ×4 | → | false |
| a small triangle over London, point inside | lat 51.52, lng -0.1, polygon ×3 | → | true |
| a small triangle over London, point outside near the apex | lat 51.59, lng -0.15, polygon ×3 | → | false |
| two vertices are not a polygon | lat 0, lng 0, polygon ×2 | → | error: a polygon needs at least 3 distinct vertices |
| repeated vertices do not count twice | lat 0, lng 0, polygon ×4 | → | error: a polygon needs at least 3 distinct vertices |
| a vertex latitude out of range is an error | lat 0, lng 0, polygon ×3 | → | error: latitude must be between -90 and 90 degrees |
| a point longitude out of range is an error | lat 0, lng 200, polygon ×4 | → | error: longitude must be between -180 and 180 degrees |
More from the author
- **The boundary is inside.** A point exactly on an edge or a vertex returns true. Plain ray casting answers boundary points inconsistently (a point on the east edge of a square comes out outside, one on the west edge inside), so every edge is checked first: zero cross product and within the edge's extent. - **Even-odd rule.** A ray is cast towards increasing longitude and crossings are counted, with a half-open test on latitude so a ray passing exactly through a vertex counts it once, not twice. For a self-intersecting polygon the overlapping parts count as outside. - **Rings.** Vertices in order, either winding. Closing the ring (repeating the first vertex at the end) is optional. Fewer than 3 distinct vertices is an error. - **Coordinates** must be in -90..90 and -180..180.
## What it deliberately does not do
It works on the flat longitude/latitude plane, treating edges as straight lines in degrees. That is how GeoJSON polygons and most map tools draw them, and it is accurate for zones of city or county size. It does not handle holes (test the outer ring, then each hole), polygons that cross the antimeridian (split them), or polygons that contain a pole.
## Floating point
Only + − × ÷ and comparisons are used, in the same order in TypeScript, Python and Rust, so all three give the same answer for every input, including points so close to an edge that rounding decides them. Boundary detection is exact when the coordinates' products are exact (integers, or few decimals at small scale); for a point within about 1e-15 degrees of an edge given in arbitrary decimals, rounding decides, identically in every language. On 4,000 random cases, half of them on an integer grid to hit edges and vertices, the three languages agreed on every answer.
Files
| Path | Bytes |
|---|---|
| README.md | 1,942 |
| impl/python.py | 3,462 |
| impl/rust.rs | 3,362 |
| impl/typescript.ts | 3,158 |
| vectors.json | 4,648 |