Skip to content

Minimum Bounding Circle

The minimum bounding circle (also called smallest enclosing circle) of a set of points is the smallest circle that contains all the points.

GeometryOps provides Welzl's algorithm for computing this in expected O(n) time.

Example

julia
import GeometryOps as GO, GeoInterface as GI

points = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)]
circle = GO.minimum_bounding_circle(points)
GO.radius(circle)  # approximately 0.707
julia
export minimum_bounding_circle, PlanarCircle, radius

"""
    PlanarCircle{T}

A circle in 2D Euclidean space, represented by its center and squared radius.

Use `radius(circle)` to get the actual radius (computes sqrt).

!!! warning "Experimental"
    This type is not part of the public API and is subject to change without a breaking version.
    It implements GeoInterface's `PolygonTrait`, so code using GeoInterface methods will remain
    compatible even if the concrete type changes.

# Fields
- `center::Tuple{T, T}`: The (x, y) coordinates of the center
- `radius_squared::T`: The squared radius (avoids sqrt in distance comparisons)
"""
struct PlanarCircle{T}
    center::Tuple{T, T}
    radius_squared::T
end

"""
    radius(circle::PlanarCircle)

Return the radius of the circle. Computes `sqrt(radius_squared)`.
"""
radius(c::PlanarCircle) = sqrt(c.radius_squared)

"""
    PlanarCircleRing{T}

A lazy wrapper that presents a `PlanarCircle` as a `LinearRing` with interpolated points.
Used internally for GeoInterface compatibility.

!!! warning "Internal"
    This is an internal type and not part of the public API.
"""
struct PlanarCircleRing{T}
    circle::PlanarCircle{T}
end

GeoInterface for PlanarCircle (PolygonTrait)

julia
GI.geomtrait(::PlanarCircle) = GI.PolygonTrait()
GI.nring(::GI.PolygonTrait, ::PlanarCircle) = 1
GI.getring(::GI.PolygonTrait, c::PlanarCircle, i::Int) = PlanarCircleRing(c)
GI.getexterior(::GI.PolygonTrait, c::PlanarCircle) = PlanarCircleRing(c)
GI.gethole(::GI.PolygonTrait, ::PlanarCircle) = ()

function GI.npoint(::GI.PolygonTrait, c::PlanarCircle)
    return GI.npoint(GI.LinearRingTrait(), PlanarCircleRing(c))
end

GeoInterface for PlanarCircleRing (LinearRingTrait)

julia
GI.geomtrait(::PlanarCircleRing) = GI.LinearRingTrait()
GI.npoint(::GI.LinearRingTrait, ::PlanarCircleRing) = 101  # 100 segments + closing point

function GI.getpoint(::GI.LinearRingTrait, r::PlanarCircleRing, i::Int)
    n = GI.npoint(GI.LinearRingTrait(), r)
    nsegs = n - 1
    idx = mod1(i, nsegs)
    θ = * (idx - 1) / nsegs
    rad = radius(r.circle)
    sinθ, cosθ = sincos(θ) # faster than sin/cos separately
    x = r.circle.center[1] + rad * cosθ
    y = r.circle.center[2] + rad * sinθ
    return (x, y)
end

"""
    Welzl{M <: Manifold} <: ManifoldIndependentAlgorithm{M}

Welzl's algorithm for computing the minimum bounding circle.

This is a randomized algorithm with expected O(n) time complexity.
Works on any manifold given an appropriate distance function.

# Constructor

    Welzl(; manifold=Planar())

# Example

```julia
import GeometryOps as GO

points = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)]
circle = GO.minimum_bounding_circle(GO.Welzl(), points)
```
"""
struct Welzl{M <: Manifold} <: ManifoldIndependentAlgorithm{M}
    manifold::M
end

Welzl(; manifold::Manifold=Planar()) = Welzl(manifold)

GeometryOpsCore.manifold(alg::Welzl) = alg.manifold

Helper: check if point is inside or on circle (using squared distance)

julia
function _point_in_circle(::Planar, p, circle::PlanarCircle)
    isnan(circle.radius_squared) && return false
    dx = p[1] - circle.center[1]
    dy = p[2] - circle.center[2]
    dist_squared = dx * dx + dy * dy

Use small epsilon for floating point tolerance

julia
    return dist_squared <= circle.radius_squared * (1 + eps(Float64) * 10)
end

Create circle with diameter from p1 to p2

julia
function _circle_from_two_points(m::Planar, p1, p2)
    T = promote_type(typeof(p1[1]), typeof(p2[1]))
    center = ((p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2)
    dx = p1[1] - center[1]
    dy = p1[2] - center[2]
    radius_squared = dx * dx + dy * dy
    return PlanarCircle{T}(center, radius_squared)
end

Create circumcircle of three points

julia
function _circle_from_three_points(m::Planar, p1, p2, p3)
    T = promote_type(typeof(p1[1]), typeof(p2[1]), typeof(p3[1]))

    ax, ay = p1[1], p1[2]
    bx, by = p2[1], p2[2]
    cx, cy = p3[1], p3[2]

    d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))

Collinear points - fall back to two-point circle using farthest pair

julia
    if abs(d) < eps(T) * 100
        d12 = (ax - bx)^2 + (ay - by)^2
        d23 = (bx - cx)^2 + (by - cy)^2
        d13 = (ax - cx)^2 + (ay - cy)^2
        if d12 >= d23 && d12 >= d13
            return _circle_from_two_points(m, p1, p2)
        elseif d23 >= d13
            return _circle_from_two_points(m, p2, p3)
        else
            return _circle_from_two_points(m, p1, p3)
        end
    end

    ux = ((ax^2 + ay^2) * (by - cy) + (bx^2 + by^2) * (cy - ay) + (cx^2 + cy^2) * (ay - by)) / d
    uy = ((ax^2 + ay^2) * (cx - bx) + (bx^2 + by^2) * (ax - cx) + (cx^2 + cy^2) * (bx - ax)) / d

    center = (ux, uy)
    radius_squared = (ax - ux)^2 + (ay - uy)^2

    return PlanarCircle{T}(center, radius_squared)
end

Create minimum circle from 0-3 boundary points

julia
function _make_circle(m::Planar, boundary::Vector)
    if isempty(boundary)
        return PlanarCircle((NaN, NaN), NaN)
    elseif length(boundary) == 1
        T = typeof(boundary[1][1])
        return PlanarCircle{T}(boundary[1], zero(T))
    elseif length(boundary) == 2
        return _circle_from_two_points(m, boundary[1], boundary[2])
    else
        return _circle_from_three_points(m, boundary[1], boundary[2], boundary[3])
    end
end

Recursive Welzl algorithm points: all points to consider idx: current index (1-based, processes points[idx:end]) boundary: points that must be on the circle boundary (0-3 points)

julia
function _welzl!(m::Manifold, points::Vector, idx::Int, boundary::Vector)

Base case: no more points or 3 boundary points define a unique circle

julia
    if idx > length(points) || length(boundary) == 3
        return _make_circle(m, boundary)
    end

    p = points[idx]

Recursively compute circle without p

julia
    circle = _welzl!(m, points, idx + 1, boundary)

If p is inside the circle, we're done

julia
    if _point_in_circle(m, p, circle)
        return circle
    end

Otherwise, p must be on the boundary of the minimum circle

julia
    push!(boundary, p)
    result = _welzl!(m, points, idx + 1, boundary)
    pop!(boundary)

    return result
end

"""
    minimum_bounding_circle([algorithm], geometry)

Compute the minimum bounding circle of `geometry`.

Returns a circle geometry containing all points of the input. For planar geometries,
returns a `PlanarCircle`; for spherical geometries, returns a `SphericalCap`.

!!! warning "Return type subject to change"
    The concrete return type (currently `PlanarCircle` for planar manifold) may change
    in future versions without a breaking release. However, the return type will always
    implement GeoInterface, so code using GeoInterface methods (e.g., `GI.getexterior`,
    `GI.getpoint`) will remain compatible.

# Arguments
- `algorithm`: The algorithm to use. Defaults to `Welzl()` which uses Welzl's expected O(n) algorithm.
- `geometry`: Any geometry compatible with GeoInterface, or a vector of point-like objects.

# Example

```julia
import GeometryOps as GO, GeoInterface as GI

From points

julia
points = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]
circle = GO.minimum_bounding_circle(points)

From any geometry

julia
polygon = GI.Polygon([[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]])
circle = GO.minimum_bounding_circle(polygon)

Access via GeoInterface for forward compatibility

julia
ring = GI.getexterior(circle)
```
"""
function minimum_bounding_circle end

minimum_bounding_circle(geom) = minimum_bounding_circle(Welzl(), geom)

function minimum_bounding_circle(alg::Welzl{Planar}, geom)

Extract all points as tuples

julia
    points = collect(flatten(tuples, GI.PointTrait, geom))

Handle edge cases

julia
    if isempty(points)
        return PlanarCircle((NaN, NaN), NaN)
    elseif length(points) == 1
        T = typeof(points[1][1])
        return PlanarCircle{T}(points[1], zero(T))
    end

Shuffle for expected O(n) performance

julia
    shuffled = Random.shuffle(points)

Initialize empty boundary

julia
    T = typeof(shuffled[1][1])
    boundary = Tuple{T, T}[]

    return _welzl!(alg.manifold, shuffled, 1, boundary)
end


function minimum_bounding_circle(alg::Welzl{<: Spherical}, geom)

Extract all points as UnitSphericalPoints

julia
    points = collect(flatten(UnitSpherical.UnitSphereFromGeographic(), GI.PointTrait, geom))

Handle edge cases

julia
    if isempty(points)
        return SphericalCap(UnitSphericalPoint(NaN, NaN, NaN), NaN, NaN)
    elseif length(points) == 1
        T = typeof(points[1][1])
        return SphericalCap{T}(points[1], zero(T), one(T))
    end

Shuffle for expected O(n) performance

julia
    shuffled = Random.shuffle(points)

Initialize empty boundary

julia
    T = typeof(shuffled[1][1])
    boundary = UnitSphericalPoint{T}[]

    return _welzl!(alg.manifold, shuffled, 1, boundary)
end

Create minimum circle from 0-3 boundary points

julia
function _make_circle(m::Spherical, boundary::Vector)
    if isempty(boundary)
        return SphericalCap(UnitSphericalPoint(NaN, NaN, NaN), NaN, NaN)
    elseif length(boundary) == 1
        T = typeof(boundary[1][1])
        return SphericalCap{T}(boundary[1], zero(T), one(T))
    elseif length(boundary) == 2
        return _circle_from_two_points(m, boundary[1], boundary[2])
    else
        return _circle_from_three_points(m, boundary[1], boundary[2], boundary[3])
    end
end

function _circle_from_two_points(m::Spherical, p1::UnitSphericalPoint, p2::UnitSphericalPoint)
    midpoint = UnitSpherical.slerp(p1, p2, 0.5)
    radius = UnitSpherical.spherical_distance(p1, p2) / 2
    return SphericalCap(midpoint, radius)
end

function _circle_from_three_points(m::Spherical, p1::UnitSphericalPoint, p2::UnitSphericalPoint, p3::UnitSphericalPoint)
    return SphericalCap(p1, p2, p3)
end

TODO: replace this internal calculation with some other thing using distancelike

julia
function _point_in_circle(m::Spherical, p::UnitSphericalPoint, c::SphericalCap)
    isnan(c.radiuslike) && return false

For point inside cap: angular_distance(p, center) <= radius Equivalently: cos(angular_distance) >= cos(radius) Since p ⋅ center = cos(angular_distance) and radiuslike = cos(radius):

julia
    return (p  c.point) >= c.radiuslike
end

This page was generated using Literate.jl.