Skip to content

Geometry & dimensions

Geometry values are instances of Geometry: one of seven immutable, hashable OGC Simple Features types, each optionally carrying a CRS and per-vertex Z (elevation) and M (measure) ordinates.

Type Made of Constructor
Point one coordinate gm.Point
LineString ordered vertices gm.LineString
Polygon a shell + holes gm.Polygon / gm.box
MultiPoint many points gm.MultiPoint
MultiLineString many lines gm.MultiLineString
MultiPolygon many polygons gm.MultiPolygon
GeometryCollection any mix gm.GeometryCollection

Always (x, y) — lon/lat only under a geographic CRS

Every constructor takes coordinates in (x, y) order. Under a geographic CRS those axes are (longitude, latitude) in that CRS's native angular units (such as degrees, radians, or grads). Projected CRSs use their linear axes; CRS-free geometries use raw coordinates. Pass crs=4326 for WGS 84 longitude/latitude in degrees.

Points

Construct a point with gm.Point(x, y); under a geographic CRS, (x, y) is (longitude, latitude) in that CRS's native angular units.

import gometry as gm

paris = gm.Point(2.3522, 48.8566, crs=4326)
print(paris.to_wkt())
print("x:", paris.x, "y:", paris.y)
print("crs:", paris.crs, "coordinate_axes:", paris.coordinate_axes)
POINT (2.3522 48.8566)
x: 2.3522 y: 48.8566
crs: EPSG:4326 coordinate_axes: XY

.x / .y are for single points only

A Point exposes .x, .y (and .z / .m when present) for scalar access. For any other geometry, pull every vertex out with geom.coords (see Inspecting geometries).

LineStrings

A LineString is an ordered sequence of vertices. Pass any sequence of (x, y) pairs or an (N, 2) NumPy-style array — gometry copies the coordinates once into its Rust geometry representation, so large inputs avoid per-point Python object construction.

import numpy as np
import gometry as gm

coords = np.array([(0, 0), (1, 2), (3, 1), (4, 3)], dtype=float)
line = gm.LineString(coords)
print(line.to_wkt())
print("length:", line.length)
LINESTRING (0 0, 1 2, 3 1, 4 3)
length: 6.708203932499369
Four ordered LineString vertices
The LineString follows all four vertices in order from left to right.

Polygons

A Polygon is a shell (exterior ring) plus zero or more holes (interior rings). Rings are closed automatically — you do not need to repeat the first vertex at the end, though it is harmless if you do.

import gometry as gm

square_with_hole = gm.Polygon(
    [(0, 0), (4, 0), (4, 4), (0, 4)],
    holes=[[(1, 1), (2, 1), (2, 2), (1, 2)]],
)
print(square_with_hole.to_wkt())
print("holes:", len(square_with_hole.interiors))
print("area:", square_with_hole.area)
POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 2 1, 2 2, 1 2, 1 1))
holes: 1
area: 15.0
Shell and interior ring
The polygon shell encloses one square hole.

Constructors check structure, not topology

gm.Polygon rejects structurally malformed input eagerly — non-finite coordinates, or a ring with fewer than three vertices, raise a InvalidGeometryError. It does not run full OGC validity, so a self-intersecting "bowtie" is built as an invalid geometry rather than rejected. Detect it with geom.validate() and fix it with repair — see Validation.

Boxes

For the common axis-aligned rectangle, gm.box is a shortcut taking (minx, miny, maxx, maxy):

import gometry as gm

extent = gm.box(0, 0, 10, 5)
print(extent.to_wkt())
print("bounds:", extent.bounds)
POLYGON ((0 0, 10 0, 10 5, 0 5, 0 0))
bounds: (0.0, 0.0, 10.0, 5.0)

Multi-part geometries

Multi-geometries group several parts of the same type. gm.MultiPoint accepts a sequence of coordinate pairs:

import numpy as np
import gometry as gm

cloud = gm.MultiPoint(np.random.default_rng(0).random((5, 2)).tolist())
print(cloud.geometry_type, "with", len(cloud), "points")
MultiPoint with 5 points

MultiLineString and MultiPolygon take sequences of part-coordinate sequences and part geometries respectively:

import gometry as gm

mls = gm.MultiLineString([
    [(0, 0), (1, 1)],
    [(2, 0), (3, 1)],
])
mp = gm.MultiPolygon([
    gm.box(0, 0, 1, 1),
    gm.box(2, 2, 3, 3),
])
print(mls.to_wkt())
print(mp.to_wkt())
MULTILINESTRING ((0 0, 1 1), (2 0, 3 1))
MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)), ((2 2, 3 2, 3 3, 2 3, 2 2)))

Explicit empty members are retained in their original order and count. For example, gm.MultiLineString([gm.LineString(), [(0, 0), (1, 1)]]) renders as MULTILINESTRING (EMPTY, (0 0, 1 1)); use .parts to inspect those typed empty members. Multipart members must share one coordinate-axis layout.

GeometryCollections

When parts have mixed types, use a GeometryCollection:

import gometry as gm

gc = gm.GeometryCollection([
    gm.Point(0, 0),
    gm.LineString([(1, 1), (2, 2)]),
    gm.box(3, 3, 4, 4),
])
print(gc.to_wkt())
GEOMETRYCOLLECTION (POINT (0 0), LINESTRING (1 1, 2 2), POLYGON ((3 3, 4 3, 4 4, 3 4, 3 3)))

Collection area counts overlaps once; length sums members

.area measures the union of a collection's areal support, so overlapping members count once. .length sums every member's length or perimeter, including overlapping members. Points and lines contribute zero area. Prefer homogeneous multi-types when you want a single-kind column; reserve collections for genuinely heterogeneous results (e.g. an overlay that returns both lines and points, or the tessellation operations).

Inspecting geometries

These properties and functions apply to every geometry type:

Call Returns
geom.geometry_type the type name, e.g. "Polygon"
geom.coordinate_axes ordinate layout: "XY", "XYZ", "XYM", "XYZM"
geom.is_empty whether the geometry holds no coordinates
bounds (minx, miny, maxx, maxy) tuple
geom.coords every vertex as a flat, indexable Coordinates view
geom.parts component geometries of a multi-part
poly.exterior / poly.interiors the shell and hole rings of a Polygon
import gometry as gm

poly = gm.Polygon(
    [(0, 0), (4, 0), (4, 4), (0, 4)],
    holes=[[(1, 1), (2, 1), (2, 2), (1, 2)]],
)

print("type:", poly.geometry_type)
print("coordinate_axes:", poly.coordinate_axes)
print("bounds:", poly.bounds)
print("vertices:", len(poly.coords))
print("parts:", len(poly.parts))
print("holes:", len(poly.interiors))
type: Polygon
coordinate_axes: XY
bounds: (0.0, 0.0, 4.0, 4.0)
vertices: 10
parts: 1
holes: 1

geom.coords is a flat, indexable Coordinates view over every vertex (each ring, each part, flattened depth-first). Index it for tuples, iterate it, or pull whole axes as columns and hand them straight to NumPy, pandas, or polars without a Python loop:

import numpy as np
import gometry as gm

coords = gm.LineString([(0, 0), (1, 2), (3, 1)], z=[10.0, 20.0, 30.0]).coords

print("len:", len(coords))
print("first tuple:", coords[0])
print("axes:", coords.coordinate_axes)
print("x column:", repr(np.asarray(coords.x)))  # zero-copy ndarray
print("dict keys:", list(coords.to_dict().keys()))
len: 3
first tuple: (0.0, 0.0, 10.0)
axes: XYZ
x column: array([0., 1., 3.])
dict keys: ['x', 'y', 'z']

.x/.y (and .z/.m when present) are zero-copy columns; .to_dict() returns read-only NumPy columns such as {'x': ndarray, 'y': ndarray} for direct use with pd.DataFrame or pl.DataFrame (forced Z/M use NaN where absent); np.asarray(coords) materializes an (N, dims) array; and .row_index tags each vertex with its source row when the geometry is a GeometryArray.

Geometry types

Every geometry is an instance of a concrete subclass of Geometry — Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, or GeometryCollection — so isinstance works and constructors/operations return the precise type:

import gometry as gm

print(type(gm.Point(1, 2)).__name__)
print(type(gm.box(0, 0, 1, 1)).__name__)
print(isinstance(gm.box(0, 0, 1, 1), gm.Polygon))
Point
Polygon
True

The multi-part classes are also registered as collections.abc.Sequence, so isinstance checks and ABC-based dispatch recognize the sequence interface. Their static types are Sequence[Point], Sequence[LineString], Sequence[Polygon], or Sequence[Geometry]. Use class patterns such as MultiPoint(parts) for matching; anonymous sequence patterns do not match geometry objects. NumPy still treats each multipart geometry as one object: np.asarray(multi) has shape (), and np.array([multi, multi], dtype=object) has shape (2,).

import gometry as gm

poly = gm.Polygon([(0, 0), (4, 0), (4, 4), (0, 4)], holes=[[(1, 1), (2, 1), (2, 2), (1, 2)]])
print("exterior:", poly.exterior.geometry_type, "| holes:", len(poly.interiors))

mp = gm.MultiPolygon([gm.box(0, 0, 1, 1), gm.box(2, 2, 3, 3)])
print("parts:", len(mp), "| first:", mp[0].geometry_type, "| areas:", [p.area for p in mp])
exterior: LineString | holes: 1
parts: 2 | first: Polygon | areas: [1.0, 1.0]

Accessing a member on the wrong type raises AttributeError: line.x fails because scalar .x is a Point property. Use line.coords.x for its X column.

Equality and hashing

Geometries are value objects: == and hash() compare the CRS, coordinate epoch, and exact geometry (type, coordinates including Z/M, and vertex order), so geometries work in sets and as dict keys:

import gometry as gm

a = gm.LineString([(0, 0), (1, 1)])
b = gm.LineString([(0, 0), (1, 1)])
reversed_ = gm.LineString([(1, 1), (0, 0)])
print("a == b:", a == b, "| a == reversed:", a == reversed_)
print("distinct:", len({a, b, reversed_}))
a == b: True | a == reversed: False
distinct: 2

This == is structural (vertex-order sensitive). For the topological "same shape on the map" test that ignores vertex order, use gm.equals; for a representation-independent vertex check, apply normalize to both inputs before calling gm.equals_exact.

Dimensions: XY, XYZ, XYM, XYZM

gometry supports four coordinate layouts — XY, XYZ, XYM, and XYZM — from the constructors through IO.

Three meanings of "dimension"

"Dimension" has three distinct meanings in GIS, and gometry keeps them separate:

Concept Property Example value
Topological dimension geom.topological_dimension 0 (Point), 1 (LineString), 2 (Polygon)
Coordinate axes / ordinate layout geom.coordinate_axes "XY", "XYZ", "XYM", "XYZM"
Has-ordinate flags geom.has_z, geom.has_m True / False
import gometry as gm

trace = gm.LineString(
    [(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)],
    z=[10.0, 20.0, 30.0],
    m=[0.0, 1.0, 2.0],
    crs=4326,
)
print("geometry_type:", trace.geometry_type)
print("topological_dimension:", trace.topological_dimension)
print("coordinate_axes:", trace.coordinate_axes)
print("has_z / has_m:", trace.has_z, "/", trace.has_m)
geometry_type: LineString
topological_dimension: 1
coordinate_axes: XYZM
has_z / has_m: True / True

Z is a spatial axis; M is a measure

  • Z is an additional spatial ordinate, usually elevation or height. gometry does not assume Z is in meters unless a vertical CRS says so — Z is a third spatial number until a CRS gives it units.
  • M is a measure ordinate. It commonly carries linear-referencing values, route measures, distance-along-line, per-vertex timestamps, or application-specific event alignment. M is not a spatial axis and must never be silently treated as Z. M is also distinct from the coordinate epoch (geom.epoch): the epoch is a single decimal year that dates the whole geometry's CRS realization, whereas M is a free per-vertex value with no datum.

A GPX track or vehicle trace has both elevation (Z) and a capture timestamp or route measure (M) aligned to each vertex. gometry lets you store both on the geometry instead of the common workaround of a 2D line plus side arrays:

import gometry as gm

track = gm.LineString(
    [(21.00, 52.00), (21.01, 52.01), (21.02, 52.00)],
    z=[105.0, 118.0, 110.0],
    m=[1_700_000_000, 1_700_000_030, 1_700_000_060],
    crs=4326,
)
coords = list(track.coords.select('XYZM'))
print("coordinate_axes:", track.coordinate_axes)
for x, y, z, m in coords:
    print(f"  lon={x} lat={y} elev={z} t={int(m)}")
coordinate_axes: XYZM
  lon=21.0 lat=52.0 elev=105.0 t=1700000000
  lon=21.01 lat=52.01 elev=118.0 t=1700000030
  lon=21.02 lat=52.0 elev=110.0 t=1700000060

coords.select(...) chooses a fixed coordinate-view layout for nested output, iteration, column access, ndarray/dict exports, and equality without changing the geometry. Nested coordinate export preserves missing geometry rows as None; flattened columns omit their coordinates.

Explicit constructors identify ambiguous tuples

A bare 3-tuple (x, y, v) is ambiguous — it could be XYZ or XYM. Use the z= and m= keywords to identify the layout:

import gometry as gm

p_xy   = gm.Point(21.0, 52.0, crs=4326)
p_xyz  = gm.Point(21.0, 52.0, z=105.0, crs=4326)
p_xym  = gm.Point(21.0, 52.0, m=1_700_000_000.0, crs=4326)
p_xyzm = gm.Point(21.0, 52.0, z=105.0, m=1_700_000_000.0, crs=4326)

for p in (p_xy, p_xyz, p_xym, p_xyzm):
    print(p.coordinate_axes, "→", p.to_wkt())
XY → POINT (21 52)
XYZ → POINT Z (21 52 105)
XYM → POINT M (21 52 1700000000)
XYZM → POINT ZM (21 52 105 1700000000)

For arrays and lines, pass z= and m= keywords to gm.points and gm.LineString:

partial: requires coordinate arrays from the surrounding application
points_xy = gm.points(lons, lats, crs=4326)
points_zm = gm.points(lons, lats, z=elevations, m=timestamps, crs=4326)
line      = gm.LineString(xy, z=elevations, m=timestamps, crs=4326)

Consistent layout per scalar geometry

A non-collection geometry must use one coordinate-axis layout for every vertex — a LineString cannot mix XY and XYZM vertices. A GeometryCollection may hold children with different layouts, but the API makes that explicit rather than silent.

WKT carries the Z, M, and ZM dimensional tags, which round-trip through gometry:

import gometry as gm

print(gm.from_wkt("POINT Z (1 2 3)").coordinate_axes)
print(gm.from_wkt("POINT M (1 2 7.5)").coordinate_axes)
print(gm.from_wkt("POINT ZM (1 2 3 7.5)").coordinate_axes)
XYZ
XYM
XYZM

Measuring with Z and M

3D spatial measurement uses the Z axis. length_3d and point-to-point distance_3d(other) include the vertical component, and min_z / max_z (properties) summarize the Z extent that complements the XY bounds. Because a 3D length only makes sense when every axis shares one linear unit, these require a projected CRS (or no CRS); a geographic CRS mixes degrees and metre heights and raises under every unit= — reproject first.

They report the CRS's own linear unit, exactly like their 2D siblings: a US-survey-foot CRS gives feet from length_3d, as it does from length. The free functions gm.length_3d and gm.distance_3d take the same unit= override as gm.length and gm.distance when you want SI metres, projected native linear units, or CRS-free raw coordinate units.

On a geometry without Z, accessors and metrics have different results. The accessors (min_z / max_z) describe what is there, returning None for a scalar or nan for an array element. The metrics (length_3d / distance_3d) require Z to compute: a scalar raises, while an array degrades per element to nan for the rows that lack Z.

import gometry as gm
climb = gm.LineString([(0.0, 0.0), (3.0, 4.0), (3.0, 4.0)], z=[0.0, 0.0, 10.0], crs=32634)
print('length_3d:', climb.length_3d)
print('z extent:', climb.min_z, climb.max_z)
a = gm.Point(0.0, 0.0, z=0.0, crs=32634)
b = gm.Point(3.0, 4.0, z=12.0, crs=32634)
print('distance_3d:', gm.distance_3d(a, b))
length_3d: 15.0
z extent: 0.0 10.0
distance_3d: 13.0

Linear referencing

Linear referencing uses the geometry's CRS metric by default. Values are absolute distances unless normalized=True, when 0.0 and 1.0 mean the start and end. Pass basis='m' when the line's M ordinate is the route measure instead.

Task Distance basis (default) M basis
point at a location line_interpolate(at) line_interpolate(at, basis='m')
location nearest a point line_locate(point) line_locate(point, basis='m')
portion between two locations line_substring(start, end) line_substring(start, end, basis='m')
import gometry as gm

line = gm.LineString([(0.0, 0.0), (10.0, 0.0)], crs=32634)
midpoint = line.line_interpolate(0.5, normalized=True)
samples = line.line_interpolate(count=3)
print('midpoint:', midpoint.to_wkt())
print('samples:', [point.to_wkt() for point in samples])
midpoint: POINT (5 0)
samples: ['POINT (0 0)', 'POINT (5 0)', 'POINT (10 0)']

Distances and route positions

With CRS-aware units (the default on a geographic CRS), linework edges are straight in the CRS's longitude/latitude chart and LRS measures the ellipsoidal arclength of the retained chart-straight source edge, rather than geodesic distance between each pair of vertices.

line_locate minimizes against those same chart edges. When backend distances compare equal, it returns the interior projection before a point or endpoint, then uses route order. This ranks available measurements, not an exact geodesic continuum minimum. Repeated support and self-crossings can have several stations at one location, so locate/interpolate is not a unique inverse there.

An independently interpolated point has canonical longitude, while a line_substring cut stays in the retained source edge's lifted chart.

unit='planar' instead uses Cartesian distance in the stored coordinate space. Planar distance-basis locate uses the exactly nearest segment; exact ties prefer an interior projection over an endpoint, then the first segment in route order, matching planar proximity.

Zero-length X/Y segments consume no distance, but their stored vertices remain on the route.

Interpolation at an interior prefix shared by a zero-length run selects its first stored vertex; normalized 0.0 and 1.0 select the first and last stored vertices, including when the whole route has zero length. line_substring includes active-distinct stored vertices in its inclusive range, even when consecutive vertices share X/Y and differ only in Z/M. At a multipart prefix boundary, interpolation selects the preceding part's endpoint, so interpolating a locate station at that boundary need not recover the next part's nearest point.

Sampling multiple locations

On the distance basis, line_interpolate accepts a sequence of locations or count= for evenly spaced samples. With basis='m', a scalar line accepts one stored measure for a Point or a measure sequence for a GeometryArray[Point]; an array receiver accepts one measure per line row.

An array receiver returns row-aligned points for one location per row or Groups when each row has its own sample count or explicit station sequence.

Construct numeric groups from a flat NumPy payload and CSR offsets, for example gm.Groups(np.array([0, 5, 10, 2, 8]), [0, 3, 5]), and pass them as at; each route uses its corresponding stations, under either distance or M basis.

Plain numeric at sequences remain flat, including []; use Groups to request grouped output even for zero routes. An empty station row produces an empty group, a missing station row or route produces a missing group, and an empty route produces one empty point per station.

normalized and unit belong only to the distance basis, so a measured route never silently mixes two coordinate systems. On the distance basis, unit selects the route metric even when normalized=True: fractions are measured along that selected route, so changing unit can change the resulting position.

count= also uses fractions of the selected metric and accepts unit=, but rejects normalized=True because the count already defines fractional sample positions.

M-based positions

M linear referencing locates positions by the M ordinate rather than by fractional or absolute length. line_interpolate(m, basis='m') returns the point at a given measure, line_locate(point, basis='m') returns the measure nearest a point, and line_substring(start_m, end_m, basis='m') cuts the section between two measures. They require every vertex to carry a monotonic, non-decreasing M. The extent accessors mirror the Z family: min_m / max_m summarize the measure span (None for a scalar, nan for an array element, when no vertex carries M).

Within each edge, M varies linearly with arclength. For increasing endpoint measures, a requested m selects the arclength fraction (m - m0) / (m1 - m0); equal endpoint measures remain constant. Z is carried at that same fraction, and line_locate(..., basis='m') converts the nearest point's arclength fraction back to M.

On a geographic CRS this is ellipsoidal arclength of the retained chart-straight source edge; the fraction is generally different from its longitude/latitude parameter. For example, a WGS 84 meridian from latitude −10° to 10° with endpoint measures 100 and 300 places M=150 at latitude approximately −5.00037949475414°, not −5°.

Distance-basis interpolation and substring use the same Z/M carry rule; interpolate_m assigns vertex measures by cumulative edge arclength without moving X/Y/Z.

Empty routes and invalid measures

Empty linework has no positions, so every form answers it instead of raising: line_locate returns nan, line_interpolate returns POINT EMPTY, line_substring returns LINESTRING EMPTY, and interpolate_m returns the empty geometry with an M axis. A POINT EMPTY query locates to nan.

Missing or non-monotonic M is different: both scalar and array calls raise InvalidGeometryError, because they asked for measures the geometry does not have. Array failures identify the original input row; an invalid route is never converted into a successful empty result. Wrong-kind and parameter errors also raise on both.

import gometry as gm

route = gm.LineString([(0.0, 0.0), (10.0, 0.0)], m=[0.0, 100.0], crs=32634)
print("at m=50:", route.line_interpolate(50.0, basis='m'))
print("locate:", route.line_locate(gm.Point(5.0, 0.0, crs=32634), basis='m'))
print("m 20..80:", route.line_substring(20.0, 80.0, basis='m'))
at m=50: POINT M (5 0 50)
locate: 50.0
m 20..80: LINESTRING M (2 0 20, 8 0 80)

Working with coordinate sequences

segments() extracts each consecutive pair of stored vertices as a LineString, including closing ring edges and repeated coordinate pairs. It visits lines and polygon rings independently, so parts and holes never acquire connecting edges. Points and empty geometries yield no segments. A scalar returns a GeometryArray[LineString]; an array returns one segment group per source row, retaining missing rows.

line_merge(directed=True) joins line endpoints without reversing any source line. Both directed and ordinary merging stop at branching junctions. Directed merging also stops wherever a join would require reversing a source sequence. When joined endpoints share X/Y but differ in Z or M, directed merging retains both stored occurrences as consecutive vertices. Geographic endpoints also join across seam and pole aliases. Their published longitude spellings may change, with vertices inserted where needed to preserve an authored full-period edge. Inserted Z/M follow the original source arc.

Use map_coordinates for one flattened, same-shape coordinate transform. Use map_coordinate_sequences when lines and rings need independent edits or a different vertex count. Its callback receives a read-only (N, D) float64 array in each sequence's native ordinate schema. Return a matrix with any number of rows and the same D.

import gometry as gm

import numpy as np

line = gm.LineString([(0, 0), (2, 1), (4, 0)])
print("segments:", line.segments().to_wkt())
print("endpoints:", line.map_coordinate_sequences(lambda c: c[[0, -1]]).to_wkt())
routes = gm.GeometryArray([line, line])
stations = gm.Groups(np.array([0, 1, 0.25, 0.75]), [0, 2, 4])
samples = routes.line_interpolate(stations, normalized=True)
print("sample counts:", samples.counts.tolist())
segments: ['LINESTRING (0 0, 2 1)', 'LINESTRING (2 1, 4 0)']
endpoints: LINESTRING (0 0, 4 0)
sample counts: [2, 2]

Parts and rings keep their grouping, coordinate schemas, CRS, and epoch. Points receive one-coordinate matrices and can return zero or one coordinate. A multipoint receives its stored point sequence; resizing it replaces its point membership, including empty slots. Empty source components skip the callback. Empty output empties a point or line and removes a hole; a polygon becomes empty when all its rings become empty. An empty shell with nonempty holes is an error.

Callback output uses ordinary constructor admission: a nonempty line needs at least two vertices, and a nonempty ring needs three open corners or four closed vertices. Open rings are closed on all active ordinates; conflicting Z/M closure is rejected. Non-finite coordinates and changed ordinate widths raise errors. No validity repair is performed.

Z/M under operations

Planar Simple Features predicates are XY topology predicates. Whether a contains b is decided in X/Y; Z and M are ignored for the truth value but preserved on the geometry.

Constructive operations resolve Z and M independently wherever they are derivable and drop only the axis that cannot be sourced:

  • Operations whose output vertices are copied from input vertices preserve Z/M: simplify, concave_hull, reverse, the triangulations, and source vertices retained by convex_hull keep their ordinates. The convex hull's XY shape is independent of input point order (±0.0 compares as the same ordinate). When several input vertices share an XY position, a copied hull vertex comes from the first vertex in storage order, including its Z/M and signed-zero spelling.
  • Operations whose new vertices are resolvable against the input — overlay, split, snap, and linear referencing — interpolate Z/M along source segments. They retain active-distinct source vertices even when consecutive X/Y positions match; distance-based linear referencing gives zero-length X/Y segments no length. If an axis has a genuinely unsourceable output vertex (such as a box corner entering the output), or conflicting source values, that axis is dropped while the other axis may remain.
  • On geographic input, overlays and source-preserving linework — including boundary, split, snap, the default chart path of segmentize, convex_hull, nearest_points, shortest_line, minimum_clearance_line, subdivide, line_merge, coverage_union_all, and coverage_simplify carry eligible Z/M at an inserted vertex when its producer traces it to a source edge. Z/M follow that edge's original endpoints by ellipsoidal arclength of the retained chart-straight source edge on its CRS ellipsoid, not by its chart parameter or a later cut fragment. This includes vertices inserted at the antimeridian for GeoJSON serialization.

    Source vertices retain their stored ordinate bits; when sources disagree, Z and M resolve independently and only a conflicting or unavailable axis is dropped. A planar unit= choice for a nearest witness does not change how its geographic source-edge ordinates are carried.

    A computed point with no sourceable edge has no source Z/M. Recursive subdivision retains original input-edge traces through later cuts, including seam vertices (±180° in EPSG:4326).

    On a certified zero-length source edge, an interior sample retains an axis only when both endpoint values have identical bits; a disagreement follows the operation's per-axis conflict policy. Exact source endpoints keep their own values. A nonzero edge whose measured length underflows to zero raises a metric error.

  • A degenerate minimum_rotated_rectangle that follows geographic source-edge support carries resolvable Z/M by the same original-edge arclength rule. A computed areal rectangle is a new XY construction and is returned in XY.

  • Empty overlay results retain the axes declared by the operation: difference follows its first operand, while the other set operations use the union of their operands' axes.
  • Operations that compute brand-new points — centroid, point_on_surface, envelope, buffer, voronoi_*, polylabel — cannot source Z/M for invented vertices, so they return 2D.
  • Circle solutions from maximum_inscribed_circle and minimum_bounding_circle have XY computed centers and positive-radius polygon realizations. They preserve the source CRS and coordinate epoch. A zero-radius bounding center selected from an input site retains the first source point's Z/M and stored spelling.
import gometry as gm

track = gm.LineString(
    [(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)],
    z=[10.0, 20.0, 30.0], m=[0.0, 1.0, 2.0], crs=4326,
)

centroid = track.centroid()
print("centroid:", centroid.geometry_type, centroid.coordinate_axes)

flat = track.force_2d()
print("force_2d:", flat.geometry_type, flat.coordinate_axes)
centroid: Point XY
force_2d: LineString XY
XYZM survey track
The survey track has three XY vertices carrying Z and M ordinates.
2D centroid
The computed centroid is a new 2D point, so Z and M are absent.
  • input
  • result

For a permanent 2D conversion, use force_2d to drop Z and M. force_3d fills only vertices that lack Z. Use with_coordinates to replace one or more ordinates without changing topology. Each supplied value can be a scalar broadcast over vertices or a column with one value per vertex in the flattened coordinate sequence; omitted ordinates are preserved, and z=None / m=None removes that optional axis. X and Y cannot be removed. Matrix mode replaces the coordinate matrix using its existing axis layout and cannot be mixed with ordinate columns:

import gometry as gm

track = gm.LineString([(0, 0), (1, 1), (2, 0)], z=[10, 20, 30], crs=4326)
flat = track.force_2d()
print("force_2d axes:", flat.coordinate_axes)
print("flat centroid:", (flat.centroid()).to_wkt())
print("force_3d axes:", flat.force_3d().coordinate_axes)
print("with_coordinates(z=...):", flat.with_coordinates(z=10.0))
print("with_coordinates(m=...):", flat.with_coordinates(m=0.0).coordinate_axes)
force_2d axes: XY
flat centroid: POINT (1 0.5)
force_3d axes: XYZ
with_coordinates(z=...): LINESTRING Z (0 0 10, 1 1 10, 2 0 10)
with_coordinates(m=...): XYM

IO and format limits

WKT, WKB/EWKB, and GeoArrow preserve both ordinates. to_geojson() raises on M, and __geo_interface__ raises on M or a coordinate epoch because GeoJSON has no slots for them. WKB/EWKB preserve M but not epoch; GeoArrow preserves epoch metadata. WKT/WKB preserve dimensional-empty axes, while GeoJSON does not. Format details are in Text & binary formats; GeoArrow's columnar metadata is described in Arrow & storage.

Enforcing a dimension contract

Use axes= with gm.require to require a coordinate layout. For example, require XY when your storage must not carry Z or M:

partial: requires an input object from the surrounding application
area = gm.require(obj, crs=4326, axes="XY")

trace = gm.require(obj, crs=4326, axes="XYZM")

See Validation for topology checks and ingestion examples.

See also