diff --git a/GeoSpatialTools/__init__.py b/GeoSpatialTools/__init__.py
index 11e1e9388c3b2a90fa76a7649632b55e77c63ef1..e3a054eb5a7cc0269477bf2e346f51f4071b7370 100644
--- a/GeoSpatialTools/__init__.py
+++ b/GeoSpatialTools/__init__.py
@@ -1,10 +1,12 @@
 from .neighbours import find_nearest
 from .distance_metrics import haversine
+from .great_circle import GreatCircle
 from .quadtree import Ellipse, QuadTree, Record, Rectangle
 from .kdtree import KDTree
 
 __all__ = [
     "Ellipse",
+    "GreatCircle",
     "KDTree",
     "QuadTree",
     "Record",
diff --git a/GeoSpatialTools/great_circle.py b/GeoSpatialTools/great_circle.py
new file mode 100644
index 0000000000000000000000000000000000000000..b2cb6d9b8fa1430845e6f52bed196d7fd7d0aa7b
--- /dev/null
+++ b/GeoSpatialTools/great_circle.py
@@ -0,0 +1,305 @@
+"""Class for a Great Circle object."""
+
+import numpy as np
+
+from .distance_metrics import bearing, gcd_slc
+
+
+def cartesian_to_lonlat(
+    x: float,
+    y: float,
+    z: float,
+    to_radians: bool = False,
+) -> tuple[float, float]:
+    """
+    Get lon, and lat from cartesian coordinates.
+
+    Parameters
+    ----------
+    x : float
+        x coordinate
+    y : float
+        y coordinate
+    z : float
+        z coordinate
+    to_radians : bool
+        Return angles in radians. Otherwise return values in degrees.
+
+    Returns
+    -------
+    (float, float)
+    lon, lat
+    """
+    R = np.sqrt(x**2 + y**2 + z**2)
+    x /= R
+    y /= R
+    z /= R
+    lat = np.arcsin(z)
+    tmp = np.cos(lat)
+    sign = np.arcsin(y / tmp)
+    lon = np.arccos(x / tmp) * sign
+    if to_radians:
+        return lon, lat
+    return np.degrees(lon), np.degrees(lat)
+
+
+def polar_to_cartesian(
+    lon: float,
+    lat: float,
+    R: float = 6371,
+    to_radians: bool = True,
+    normalised: bool = True,
+) -> tuple[float, float, float]:
+    """
+    Convert from polars coordinates to cartesian.
+
+    Get cartesian coordinates from spherical polar coordinates. Default
+    behaviour assumes lon and lat, so converts to radians. Set
+    `to_radians=False` if the coordinates are already in radians.
+
+    Parameters
+    ----------
+    lon : float
+        Longitude.
+    lat : float
+        Latitude.
+    R : float
+        Radius of sphere.
+    to_radians : bool
+        Convert lon and lat to radians.
+    normalised : bool
+        Return normalised vector (ignore R value).
+
+    Returns
+    -------
+    (float, float, float)
+        x, y, z cartesian coordinates.
+    """
+    theta = np.radians(lon) if to_radians else lon
+    phi = np.radians(lat) if to_radians else lat
+    x = np.cos(theta) * np.cos(phi)
+    y = np.sin(theta) * np.cos(phi)
+    z = np.sin(phi)
+    return (x, y, z) if normalised else (R * x, R * y, R * z)
+
+
+class GreatCircle:
+    """
+    A GreatCircle object for a pair of positions.
+
+    Construct a great circle path between a pair of positions.
+
+    https://www.boeing-727.com/Data/fly%20odds/distance.html
+
+    Parameters
+    ----------
+    lon0 : float
+        Longitude of start position.
+    lat0 : float
+        Latitude of start position.
+    lon1 : float
+        Longitude of end position.
+    lat1 : float
+        Latitude of end position.
+    R : float
+        Radius of the sphere. Default is Earth radius in km (6371.0).
+    """
+
+    def __init__(
+        self,
+        lon0: float,
+        lat0: float,
+        lon1: float,
+        lat1: float,
+        R: float = 6371,
+    ) -> None:
+        self.lon0 = lon0
+        self.lat0 = lat0
+        self.lon1 = lon1
+        self.lat1 = lat1
+        self.R = R
+        self.cross_prod = _cross_lonlat(
+            self.lon0, self.lat0, self.lon1, self.lat1
+        )
+        self.cross_prod_dist = np.linalg.norm(self.cross_prod)
+        self.bearing = bearing(self.lon0, self.lat0, self.lon1, self.lat1)
+        self.dist = gcd_slc(self.lon0, self.lat0, self.lon1, self.lat1)
+
+    def dist_from_point(
+        self,
+        lon: float,
+        lat: float,
+    ) -> float:
+        """
+        Compute distance from the GreatCircle to a point on the sphere.
+
+        Parameters
+        ----------
+        lon : float
+            Longitude of the position to test.
+        lat : float
+            Longitude of the position to test.
+
+        Returns
+        -------
+        float
+            Minimum distance between point and the GreatCircle arc.
+        """
+        cart = polar_to_cartesian(lon, lat, normalised=True)
+        num = np.dot(cart, self.cross_prod)
+        # WARN: This can be negative - hence using abs
+        return np.abs(np.arcsin(num / self.cross_prod_dist) * self.R)
+
+    def _identical_plane(
+        self,
+        other: object,
+        epsilon: float = 0.01,
+    ) -> bool:
+        """
+        Identify if other GreatCircle has the same plane.
+
+        Determined by comparing the norms of the planes constructed from the
+        two points and the centre of the sphere.
+
+        Returns True if the planes formed by the two great circles are
+        parallel, i.e. the normals defining the planes have the same
+        direction, or the exact opposite direction. This would mean that a
+        GreatCircle compared with the GreatCircle with oppposite start and
+        end points would return True.
+
+        Parameters
+        ----------
+        other : GreatCircle
+            Intersecting GreatCircle object
+        epsilon : float
+            Threshold for intersection
+
+        Returns
+        -------
+        bool
+            Indicating if the planes formed by two GreatCircle objects are the
+            same (or mirrored) to within a given threshold.
+        """
+        if not isinstance(other, GreatCircle):
+            raise TypeError("Input 'other' is not a GreatCircle")
+        return bool(
+            np.isclose(self.cross_prod, other.cross_prod, atol=epsilon).all()
+            or np.isclose(
+                self.cross_prod, -other.cross_prod, atol=epsilon
+            ).all()
+        )
+
+    def intersection(
+        self, other: object, epsilon: float = 0.01
+    ) -> tuple[float, float] | None:
+        """
+        Determine intersection position with another GreatCircle.
+
+        Determine the location at which the GreatCircle intersects another
+        GreatCircle arc. (To within some epsilon threshold).
+
+        Returns `None` if there is no solution - either because there is no
+        intersection point, or the planes generated from the arc and centre of
+        the sphere are identical.
+
+        Parameters
+        ----------
+        other : GreatCircle
+            Intersecting GreatCircle object
+        epsilon : float
+            Threshold for intersection
+
+        Returns
+        -------
+        (float, float) | None
+            Position of intersection
+        """
+        if not isinstance(other, GreatCircle):
+            raise TypeError("Input 'other' is not a GreatCircle")
+        if self.R != other.R:
+            raise ValueError("GreatCircle radius values do not match")
+        if self._identical_plane(other, epsilon=epsilon):
+            return None
+        plane_intersection = np.cross(self.cross_prod, other.cross_prod)
+        epsilon *= self.R
+        S1 = plane_intersection / np.linalg.norm(plane_intersection)
+        lon, lat = cartesian_to_lonlat(*S1)
+        if self.dist_from_point(lon, lat) < epsilon:
+            return lon, lat
+        S2 = -S1
+        lon, lat = cartesian_to_lonlat(*S2)
+        if self.dist_from_point(lon, lat) < epsilon:
+            return lon, lat
+        return None
+
+    def intersection_angle(
+        self,
+        other: object,
+        epsilon: float = 0.01,
+    ) -> float | None:
+        """
+        Get angle of intersection with another GreatCircle.
+
+        Get the angle of intersection with another GreatCircle arc. Returns
+        None if there is no intersection.
+
+        The intersection angle is computed using the normals of the planes
+        formed by the two intersecting great circle objects.
+
+        Parameters
+        ----------
+        other : GreatCircle
+            Intersecting GreatCircle object
+        epsilon : float
+            Threshold for intersection
+
+        Returns
+        -------
+        float | None
+            Intersection angle in degrees
+        """
+        if not isinstance(other, GreatCircle):
+            raise TypeError("'other' must be of type 'GreatCircle'")
+        # Make sure we have an intersection!
+        if self.intersection(other, epsilon) is None:
+            return None
+        # INFO: Want to use self.cross and other.cross which are the normals
+        angle = np.arccos(
+            np.dot(self.cross_prod, other.cross_prod)
+            / (self.cross_prod_dist * other.cross_prod_dist)
+        )
+        return np.rad2deg(angle)
+
+
+def _cross_lonlat(
+    lon0: float,
+    lat0: float,
+    lon1: float,
+    lat1: float,
+) -> np.ndarray:
+    """
+    Get the cross-product between two positions on a sphere.
+
+    |u_1| x |u_2|
+
+    Parameters
+    ----------
+    lon0 : float
+        Longitude of position 0 in degrees.
+    lat0 : float
+        Latitude of position 0 in degrees.
+    lon1 : float
+        Longitude of position 1 in degrees.
+    lat1 : float
+        Latitude of position 1 in degrees.
+
+    Returns
+    -------
+    np.ndarray
+        Cartesian vector of the cross product of the input lon/lat positions
+        (assuming a sphere of radius 1).
+    """
+    return np.cross(
+        polar_to_cartesian(lon0, lat0, normalised=True),
+        polar_to_cartesian(lon1, lat1, normalised=True),
+    )
diff --git a/test/test_greatcircle.py b/test/test_greatcircle.py
new file mode 100644
index 0000000000000000000000000000000000000000..06bdab2a5120280f3a4c9f25f2cd48baa6d85cef
--- /dev/null
+++ b/test/test_greatcircle.py
@@ -0,0 +1,46 @@
+import unittest
+import numpy as np
+from GeoSpatialTools.great_circle import GreatCircle
+from GeoSpatialTools.distance_metrics import gcd_slc
+
+
+class TestGreatCircle(unittest.TestCase):
+    halifax = (-63.5728, 44.6476)
+    southampton = (-1.4049, 50.9105)
+
+    def test_constructor(self):
+        out = GreatCircle(*self.halifax, *self.southampton)
+        assert hasattr(out, "dist")
+        assert out.dist == gcd_slc(*self.southampton, *self.halifax)
+
+    def test_meridian_planes(self):
+        # TEST: That great circles from a pole stay on the same longitude
+        lon0, lat0 = 45, 23
+        GC1 = GreatCircle(0, 90, lon0, lat0)
+
+        assert GC1.dist_from_point(-lon0, lat0 + 5) > 10
+        for lat in range(lat0, 90, 2):
+            dist = GC1.dist_from_point(lon0, lat)
+            assert dist < 0.01
+
+        GC2 = GreatCircle(0, -90, lon0, -lat0)
+        assert abs(GC1.dist - GC2.dist) < 0.01
+        for lat in range(-lat0, -90, -2):
+            dist = GC2.dist_from_point(lon0, lat)
+            assert dist < 0.01
+
+        assert GC1._identical_plane(GC2)
+
+    def test_equator_vs_meridian(self):
+        # TEST: that a great circle at the equator and a line of longitude
+        #   intersect with angle 90
+        GC0 = GreatCircle(-5, 0, 5, 0)
+        GC1 = GreatCircle(0, -5, 0, 5)
+        assert np.isclose([GC0.dist], [GC1.dist]).all()
+        assert GC1.dist_from_point(0, 0) < 0.01
+        # assert GC0.dist_from_point(0, 0) < 0.01
+
+        int_pts = GC0.intersection(GC1)
+        int_ang = GC0.intersection_angle(GC1)
+        assert int_pts == (0, 0)
+        assert int_ang == 90