Switch to stable 8.0.427-dev

osm

@library("osm", "0.0.0");

OpenStreetMap toolkit for GreyCat. Ships:

  • A transient client for the Overpass API with typed JSON parsing — including fetch_from_point / fetch_from_address / fetch_from_place osmnx-style entry points.
  • A type-safe Overpass-QL builder with input-validation guards.
  • Native ring math on std::core geo / GeoBox / GeoPoly (centroid, area, bounds, closed-ring detection, segment distance, bearings, interpolation) — no osm-specific code needed.
  • An opt-in persistent graph layer that stores OsmNode / OsmWay / OsmRelation in nodeList / nodeIndex / nodeGeo collections, enabling spatial queries, durable storage, nearest_edges for GPS snapping, bbox / polygon truncation, length-weighted point sampling, and elevation enrichment via Open-Elevation-compatible APIs.
  • A graph-statistics layer with bearing histograms, orientation entropy, intersection counts, degree distribution, circuity, and a bundle basic_stats reducer (osmnx-shape).
  • GeoJSON export of the persistent graph (to_geojson) — ready for QGIS / Leaflet / Mapbox-GL / geopandas.
  • Nominatim geocoder client (forward + reverse + place-polygon extraction via geocode_to_polygon).
  • Native streaming readers for .osm.pbf / .osm / .osc (with transparent gzip) — three streaming modes per format (one-shot read, direct-to-graph import_into / apply_into, chunked OsmReader), plus an OsmReplication client for incremental Geofabrik-style updates.

The user-facing geometry types are native: geo (point), GeoBox (bounding box), GeoPoly (polygon). The OsmGeoPoint / OsmBounds types still exist but are wire-only — they exist solely to deserialize Overpass JSON, which uses lon (not lng) and minlat/maxlat field names. Lift to native types at the boundary with geo{p.lat, p.lon} (single point) or elem.bounds!!.to_box() (Overpass bounds → GeoBox).

Quick start — transient

@library("osm", "0.0.0");

fn main() {
    var bbox = GeoBox {
        sw: geo{33.895, 35.510},
        ne: geo{33.905, 35.520},
    };
    var report = OsmClient::fetch_buildings(bbox, null, null);
    if (report.error != null) {
        error("fetch failed: ${report.error}");
        return;
    }
    for (i, e in report.elements) {
        var geom = e.geometry;
        if (e.kind() == OsmElementType::way && geom != null) {
            // Lift wire geometry to native geo[] before calling helpers.
            var ring = Array<geo> {};
            for (_, p in geom) {
                ring.add(geo{p.lat, p.lon});
            }
            info("way ${e.id}: ${GeoPoly { points: ring }.area()}m2");
        }
    }
}

Use e.kind() (returns OsmElementType?) rather than raw e.type == "way" string comparisons — the enum form catches typos at compile time.

Low-level Overpass-QL

var types = Array<OsmElementType> {};
types.add(OsmElementType::node);
var ql = OsmQuery::tagged("amenity", "cafe", types, bbox, null);
var report = OsmClient::query(ql, null, null);

OsmQuery::tagged validates every tag key / value / area-iso with a strict char allowlist (A-Za-z0-9_:.-) and throws OsmQueryError on anything that could escape the quoting context — defence in depth against user-supplied input. Length caps: 64 chars for keys and area codes, 255 chars for values (matches OSM’s documented value ceiling). Empty types arrays are rejected too — Overpass would reject the resulting query anyway, so failing fast is friendlier.

OsmQuery::* throws OsmQueryError (message: String + code: OsmQueryErrorCode) on invalid input. GreyCat’s try/catch surfaces a std Error (message + stack), so the typed code is not recoverable in a catch block — branch on the stable message prefix ("invalid Overpass key", "invalid Overpass value", "invalid Overpass area iso", "dist_m must be > 0", …) or validate input up front so the call never throws.

osmnx-style “from X” entry points

For the common case of “give me everything tagged X near a point / address / named place” the client wraps the geocode + query + fetch dance:

// Callers build their own `Array<OsmElementType>` for the `types` arg —
// node hits for amenities, way hits for buildings, etc.
var node_types = Array<OsmElementType> {};
node_types.add(OsmElementType::node);
var way_types = Array<OsmElementType> {};
way_types.add(OsmElementType::way);

// 1) From a point + radius.
var hits = OsmClient::fetch_from_point(geo{33.8938, 35.5018}, 500.0,
                                       "amenity", "cafe", node_types,
                                       null, null);

// 2) From a free-text address.
var hits2 = OsmClient::fetch_from_address("Hamra, Beirut", 800.0,
                                          "amenity", null, node_types,
                                          null, null);

// 3) From a named place (uses the place's admin polygon).
var hits3 = OsmClient::fetch_from_place("Beirut, Lebanon",
                                        "building", null, way_types,
                                        null, null);

fetch_from_address first geocodes the string via Nominatim, then builds a bbox around the matched point and runs the Overpass query. fetch_from_place uses Nominatim::geocode_to_polygon to pull the place’s outer ring as Array<geo> and routes through Overpass (poly:"...") — exactly the path osmnx’s features_from_place takes.

For multi-value queries (highway in {primary, secondary, tertiary}) use OsmQuery::tag_any(key, values, types, bbox, area_iso?). For AOIs that don’t fit a bbox, OsmQuery::polygon_tagged(key, value?, types, polygon) (where polygon: Array<geo>) builds an Overpass (poly:"...") query.

Persistent graph

Opt-in. Hold one or more node<OsmGraph> at module level and merge fetches into it:

var beirut: node<OsmGraph>;

fn init() {
    beirut = OsmGraph::create();
}

fn refresh() {
    var bbox = GeoBox { sw: geo{33.85, 35.48}, ne: geo{33.95, 35.55} };
    var report = OsmClient::fetch_buildings_tiled(bbox, 2, 2, "LB", null);
    if (report.error != null) {
        error("fetch failed: ${report.error}");
        return;
    }
    var src = OsmGraphSource { kind: OsmSourceKind::overpass, detail: OsmSettings::current().default_endpoint };
    var transient = OsmResponse {
        version: null, generator: null, osm3s: null,
        elements: report.elements,
    };
    beirut->import_response(transient, src);
}

fn nearby(point: geo): node<OsmNode>? {
    return beirut->nearest_node(GeoCircle { center: point, radius: 250.0 });
}

import_response is idempotent on osm_id — re-importing the same data overwrites tags / geometry instead of duplicating. The importer runs four passes (nodes → ways → relation skeletons → relation bodies) so ways resolve to their nodes, relations to their members, and relations referencing other relations resolve regardless of element order in the response. References that don’t resolve (a way pointing at a node that wasn’t in this response and isn’t already in the graph) are dropped silently and counted in stats.unresolved_refs (per missing ref); the parent way / relation is still imported with the references it could resolve, so a partial response yields a partial geometry. stats.skipped counts whole-element drops (filtered out, no usable geometry).

When a node is re-imported with new coordinates the importer drops the old entry from nodes_by_geo before inserting the new one — spatial queries see the new location immediately, no stale ghosts.

For graph counts, read the indices directly: g->nodes_by_id.size(), g->ways_by_id.size(), g->relations_by_id.size(). There’s no aggregate stats() method — fewer types, same data.

Geocoding (Nominatim)

Nominatim is an external HTTP service — slow and rate-limited. Every call here is a round-trip to a remote server (typically 100 ms to several seconds) and the public endpoint is capped at 1 req/s by OSM’s usage policy, which this client enforces. There is no caching: the same address geocoded twice costs two round-trips. Do not put Nominatim::* on a hot path or in a tight loop over many points.

For high-volume work, either:

  • Run a private Nominatim mirror and pass its URL as endpoint — the 1 req/s throttle gate only fires when endpoint is null.
  • Use the persistent OSM graph instead. Once a region has been imported (via OsmGraph::import_response or one of the streaming readers), the nodeGeo index answers spatial queries in-process with no HTTP: OsmGraph::nearest_node, OsmGraph::nearest_edges (GPS snapping / “what street is this”), OsmGraph::nodes_in, OsmGraph::nodes_near. These are sub-millisecond and scale to the full imported region.

Reserve Nominatim for low-volume, interactive use: resolving a user-typed place name, fetching an admin boundary to seed an import, or kicking off OsmClient::fetch_from_address / OsmClient::fetch_from_place.

var hits = Nominatim::geocode("Beirut, Lebanon", null);
if (hits.size() > 0) {
    var lat = parseNumber(hits[0].lat) as float;
    var lon = parseNumber(hits[0].lon) as float;
    info("matched ${hits[0].display_name} at ${lat},${lon}");
}

var addr = Nominatim::reverse(geo{33.8938, 35.5018}, null, null);
if (addr != null) {
    info("address: ${addr.display_name}");
}

Nominatim::geocode_one(query, endpoint?) returns the best match as a geo?, matching osmnx’s single-result shape.

Nominatim::geocode_to_polygon(query, endpoint?) returns the matched place’s outer polygon ring as Array<geo>? — pair with OsmClient::fetch_polygon (or use OsmClient::fetch_from_place) when the AOI is a named region rather than a bbox or a radius.

var types = Array<OsmElementType> {};
types.add(OsmElementType::way);
types.add(OsmElementType::relation);
var poly = Nominatim::geocode_to_polygon("Achrafieh, Beirut", null);
if (poly != null) {
    var bldgs = OsmClient::fetch_polygon(poly!!, "building", null, types, null);
}

For low-level access, Nominatim::geojson_first_ring(any?) lifts an already-parsed GeoJSON Geometry value to a ring — useful when you have cached Nominatim responses and want to extract polygons offline.

Public Nominatim is rate-limited (1 req/s) and requires an identifiable User-Agent with a contact channel. The minimum inter-request interval is OsmSettings::current().nominatim_min_interval (default 1_s); drop to 0_us only when pointing at a private mirror. The default User-Agent is "greycat-osm/1.0 (+https://greycat.io)" — override it for production use (see “Settings” below) and run your own Nominatim mirror for higher throughput.

Non-200 responses are logged via warn (so server logs distinguish “no match” — a 200 with empty content — from “service unavailable”), and transport errors are logged via error. Both return null / empty array to the caller.

OSM XML / PBF / osmChange (file readers)

Three streaming modes per format, all in lib/osm/osm_readers.gcl. Gzip is auto-detected on every reader — pass .osm.gz, .osm.pbf.gz, or .osc.gz straight through (no manual decompression).

// 1) One-shot transient — whole file -> OsmResponse.
var resp = OsmXml::read("./data/monaco.osm.gz", null);
//                                               ^^^^ OsmFilter? to
//                                                    drop tags / types /
//                                                    bbox at parse time

// 2) Direct-to-graph — never materialises a full OsmResponse.
//    Build a filter at construction time (idiomatic style — easier to
//    read than empty-then-set):
var graph = OsmGraph::create();
var tags = Map<String, String?> {};
tags.set("highway", null);                       // any highway tag
var filter = OsmFilter { tags: tags, types: null, bbox: null };
var stats = OsmPbf::import_into(graph, "./data/monaco.osm.pbf", filter);
info("imported ${stats.nodes} nodes, ${stats.ways} ways");

// 3) Chunked iterator — bounded memory, ~one PBF block (~8000
//    elements) or ~1000 XML / osmChange elements per chunk. The
//    iterator auto-closes at EOF; `close()` is idempotent.
var reader = OsmPbf::open("./data/monaco.osm.pbf", null);
var chunk = reader.next();
while (chunk != null) {
    info("chunk: ${chunk.elements.size()} elements");
    chunk = reader.next();
}
reader.close(); // idempotent, safe after EOF

osmChange (.osc / .osc.gz)

OsmChange::read returns an OsmChangeResponse that splits elements into creates / modifies / deletes arrays. OsmChange::apply_into applies a diff to a node<OsmGraph>: <create> and <modify> upsert by osm_id; <delete> calls OsmGraph::remove_*(osm_id) with no cascade (matches existing graph removal semantics — see the OsmGraph::remove_* rows in the Graph table). Diffs MUST be applied in sequence order; otherwise a later re-create races against an earlier delete.

var stats = OsmChange::apply_into(graph, "./diffs/123.osc.gz", null);
info("+${stats.created} ~${stats.modified} -${stats.deleted}");

Both .osc and .osc.gz are accepted — gzip detection is transparent on every native reader.

OsmChange::open exposes a chunked iterator (next(): OsmResponse?), but chunked iteration flattens the <create> / <modify> / <delete> sections — the per-element discriminator is not carried. Use OsmChange::read (categorised whole-file decode) or OsmChange::apply_into (direct-to-graph with section semantics) when the section matters.

Replication client

OsmReplication (in lib/osm/osm_replication.gcl) wraps a Geofabrik-style replication endpoint. Pin one node<OsmReplicationState> per graph; OsmReplication::sync walks forward through every diff between state.sequence and the server’s latest, calling OsmChange::apply_into on each in order and updating state after every success. apply_file is the offline path for tests and for callers who download diffs out-of-band — it delegates straight to OsmChange::apply_into.

var rep: node<OsmReplicationState>;

fn init() {
    rep = node<OsmReplicationState> { OsmReplicationState {
        base_url: "https://download.geofabrik.de/europe/luxembourg-updates/",
        sequence: 0,
        timestamp: null,
    }};
}

fn tick() {
    var stats = OsmReplication::sync(graph, rep, null, null);
    if (stats.error != null) {
        warn("replication paused: ${stats.error}");
    }
}

OsmReader

OsmReader is a native handle (not a @volatile type) — it owns a C file handle and parser scratch on the host allocator, so persisting it is meaningless. Always close() (idempotent) or let the native finalizer collect it; either path releases the FD.

Threading and limits

  • OsmGraph is a node<> — share refs across workers freely; mutating methods (import_response, clear) are transactional per call.
  • OsmClient calls are blocking HTTP. Overpass caps at ~180 s per query and rate-limits aggressive callers. For country-sized fetches, fetch_buildings_tiled is the supported pattern: it splits the bbox, retries each tile up to OsmSettings::current().default_retries times, and dedupes elements that appear in two adjacent tiles (keyed by (kind, id)).
  • Overpass response sizes are bounded by the server, not the client — expect up to ~500 MB raw JSON on a country query; tile to fit your memory budget.

Types

User-facing geometry types come from std::coregeo, GeoBox, GeoPoly, GeoCircle. The osm-specific types listed below are either wire shapes (@volatile, exist for JSON deserialization) or persistent graph types (OsmNode / OsmWay / …).

Type Kind Purpose
OsmGeoPoint @volatile Wire-only — Overpass JSON shape (lon, not lng); lift to geo at the boundary
OsmBounds @volatile Wire-only — Overpass out bb block; lift via bounds.to_box()
OsmMember @volatile Relation member with inline geometry
OsmElement @volatile Node / way / relation as Overpass returns it
OsmOsm3s @volatile Provenance sub-block of OsmResponse
OsmResponse @volatile Top-level Overpass envelope
OsmFetchReport @volatile Diagnostics + payload from OsmClient (elements, status, duration: duration, error?)
OsmStats abstract Pure-function reductions over a way / node nodeIndex (total_length_m, total_area_m2)
OsmFilter @volatile Element selector for native readers
OsmImportStats @volatile Reader / importer outcome (nodes, ways, relations, skipped, unresolved_refs, duration: duration, error?)
OsmChangeResponse @volatile OsmChange::read output: creates / modifies / deletes arrays of OsmElement
OsmChangeStats @volatile OsmChange::apply_into / OsmReplication::* outcome (created, modified, deleted, skipped, duration, error?)
OsmReader reader Chunked-iterator handle (file descriptor + parser state; non-persistable). Returned by Osm{Pbf,Xml,Change}::open
OsmReplicationState persisted One row per replication stream (base_url, sequence, timestamp?)
OsmQueryError @volatile Thrown by validating OsmQuery::* helpers; carries message: String + code: OsmQueryErrorCode enum discriminant
NominatimResult @volatile Single geocoding match
OsmElementType enum node / way / relation
OsmSourceKind enum overpass / xml / pbf
OsmNetworkType enum drive / drive_service / walk / bike / all_public / all
OsmOrientationMode enum ridge / solar / street — picks how building_orientation faces
OsmGraphSource persisted Origin metadata stored alongside OsmGraph
OsmNode persisted One geographic point + tags + optional cached elevation
OsmWay persisted Ordered nodeList<node<OsmNode>> + optional cached length_m
OsmRelation persisted nodeList<OsmRelationMember>
OsmRelationMember persisted Exactly one *_ref non-null (node / way / relation)
OsmGraph persisted Indexed graph: nodes_by_id / ways_by_id / relations_by_id / nodes_by_geo
OsmEdgeHit @volatile nearest_edges result: way / segment_index / distance_m / projection: geo
OsmOrientation type Building facing vector: from: geo (barycenter) / to: geo / azimuth: float (deg, 0°=N) / mode: OsmOrientationMode / confidence: float

The “persisted” types are plain (non-@volatile) GCL types — they live in node<...> collections and are written to disk by the storage engine.

Functions

Client (OsmClient)

Helper Notes
query(ql, endpoint?, timeout?) Raw Overpass-QL with retry-friendly diagnostics
fetch_buildings(bbox, area_iso?, endpoint?) building=* ways + relations
fetch_highways(bbox, area_iso?, endpoint?) highway=* ways
fetch_amenities(value?, bbox, area_iso?, endpoint?) amenity=<value> nodes; value=null matches any
fetch_landuse(value?, bbox, area_iso?, endpoint?) landuse=<value> polygons; value=null matches any
fetch_by_tag(key, value?, types, bbox, area_iso?, endpoint?) Generic tag-filter fetch
fetch_network(network, bbox, area_iso?, endpoint?) Pre-built filter for OsmNetworkType
fetch_polygon(polygon, key, value?, types, endpoint?) Tag-filtered fetch inside an arbitrary Array<geo> polygon
fetch_admin_boundaries(area_iso, admin_level, endpoint?) Country admin polygons
fetch_buildings_with_retry(bbox, area_iso?, endpoint?, retries) Exponential backoff per call
fetch_buildings_tiled(bbox, rows, cols, area_iso?, endpoint?) Tile-aware bulk fetch with dedupe
fetch_from_point(point, dist_m, key, value?, types, area_iso?, endpoint?) bbox-from-point + fetch_by_tag; mirrors osmnx.features_from_point
fetch_from_address(address, dist_m, key, value?, types, nominatim_endpoint?, endpoint?) Nominatim geocode → fetch_from_point
fetch_from_place(query, key, value?, types, nominatim_endpoint?, endpoint?) Nominatim place polygon → fetch_polygon

All bbox arguments are native GeoBox.

Overpass-QL builder (OsmQuery)

Helper Notes
tagged(key, value?, types, bbox, area_iso?) Single tag filter; throws OsmQueryError (carries OsmQueryErrorCode discriminant) on unsafe key/value
tag_any(key, values, types, bbox, area_iso?) Multi-value union (key in {v1, v2, …})
polygon_tagged(key, value?, types, polygon) polygon: Array<geo>
from_point(point, dist_m, key, value?, types, area_iso?) bbox-from-point + tagged; rejects dist_m <= 0
buildings(bbox, area_iso?) tagged("building", null, way+relation)
highways(bbox, area_iso?) tagged("highway", null, way)
amenities(value?, bbox, area_iso?) tagged("amenity", value, node)
landuse(value?, bbox, area_iso?) tagged("landuse", value, way+relation)
network_type(network, bbox, area_iso?) Canned per-network-type filter
admin_boundaries(area_iso, admin_level) boundary=administrative relations
bbox_literal(bbox) "south,west,north,east"
header(timeout) [out:json][timeout:N]; (N = timeout in whole seconds)
out_clause(geom, tags, meta, bb) Trailing out modifier (canonical order)

Geometry (OsmGeometry)

outer_rings_of_relation and the orientation helpers are the OSM-specific helpers here — every other ring / point / bearing primitive lives on std::core geo / GeoBox / GeoPoly / GeoCircle:

Helper Notes
outer_rings_of_relation(elem) Lift outer rings of an OSM relation to Array<Array<geo>>
orientation_of(ring, mode, tags?) Building facing vector from a raw outer ring (Array<geo>) + optional OSM tags. Supports ridge / solar; street degrades to ridge (no graph). Works on transient (Overpass) buildings. null for < 3 vertices or no centroid. See OsmOrientationMode.
footprint_ridge(ring) Dominant-axis analysis: [ridge_bearing_deg, elongation, confidence] (empty array on degenerate input). Length-weighted edge-bearing histogram → circular-mean ridge → elongation + ±15° concentration. Backs orientation_of.
destination(from, azimuth_deg, dist_m) Geo offset dist_m metres along a compass azimuth_deg — the inverse of geo.bearing / geo.distance, which std doesn’t expose.

Building orientation

orientation_of (raw ring) and OsmGraph::building_orientation (persistent way) return an OsmOrientation — a geo vector from the footprint barycenter (from) to a footprint-scaled tip (to), plus the derived azimuth (compass degrees, 0° = N), the mode, and a confidence in [0,1]. The OsmOrientationMode picks the azimuth:

  • ridge — pure geometry: the dominant (long-axis) edge direction, returned as a deterministic perpendicular (ridge + 90°). An alignment, not a one-sided facing — the chosen side is arbitrary.
  • solar — equator-facing roof azimuth: honors OSM roof:direction / roof:shape=flat / roof:orientation tags, else the ridge-perpendicular nearest the equator, else the hemisphere optimal.
  • street — faces a tagged entrance/door node, else the nearest road in the graph, else the ridge perpendicular (graph-only mode).
// Transient (Overpass result): solar facing of the first building.
var resp = OsmClient::fetch_buildings(bbox, null, null);
for (_, e in resp.elements) {
    var g = e.geometry;
    if (e.kind() == OsmElementType::way && g != null) {
        var ring = Array<geo> {};
        for (_, p in g) {
            ring.add(geo { p.lat, p.lon });
        }
        var o = OsmGeometry::orientation_of(ring, OsmOrientationMode::solar, e.tags);
        if (o != null) {
            println("building faces ${o!!.azimuth}° (confidence ${o!!.confidence})");
        }
    }
}

// Persistent graph: which way does this building face the street?
var o = graph->building_orientation(building_way, OsmOrientationMode::street);

Statistics (OsmStats)

Pure reductions over a persistent way / node nodeIndex — pass a live tag bucket from OsmGraph::ways_tagged / nodes_tagged (null-guard it first), or graph->ways_by_id / graph->nodes_by_id for the whole graph. Iteration is lazy; no intermediate array is materialised.

Helper Notes
total_length_m(ways) Sum of great-circle segment lengths in metres across a way nodeIndex (tag bucket or graph->ways_by_id). Prefers each way’s cached length_m when set (see OsmGraph::add_way_lengths); falls back to live geometry otherwise. Skips ways with < 2 nodes.
total_area_m2(ways) Sum of GeoPoly.area() across ways with ≥ 3 nodes (the only skip). GeoPoly.area() implicitly closes the ring, so an open polyline with ≥ 3 nodes still contributes a nonzero area — pre-filter to closed ways if that matters. Antimeridian-crossing rings are handled (longitudes unwrapped), not zeroed.
way_bearing(way) Initial compass bearing (deg, [0,360)) from the way’s first node to its last. null if the way has < 2 nodes.
bearing_distribution(ways, num_bins) Bidirectional bearing histogram (Array<int> of length num_bins). Bin 0 is centered on 0° (N); uses Boeing’s double-bin trick so the 0°/360° boundary doesn’t split.
orientation_entropy(ways, num_bins) Shannon entropy (nats) of the bearing distribution. Low = grid-like, high = polar / unstructured.
orientation_entropy_of(counts) Shannon entropy (nats) over a precomputed bearing_distribution histogram — avoids re-walking ways when the histogram is already in hand.
degree_of(graph, osm_id) Number of distinct ways referencing the node — equivalent to osmnx’s per-node street_count.
degree_distribution(graph) Map<int, int> of degree → count, osmnx-shape (streets_per_node_counts).
intersection_count(graph, min_streets) Count of nodes with degree ≥ min_streets.
self_loop_count(ways) Ways where first node osm_id == last node osm_id (closed loops).
circuity(ways) Total path length / total endpoint great-circle distance. 1.0 = perfectly straight network. null if endpoint distance sum is 0.
bbox_of(nodes) Bounding GeoBox covering every node’s location in a node nodeIndex (tag bucket or graph->nodes_by_id); null for an empty index. Axis-aligned min/max in raw longitude — like way_bbox, incorrect for clusters crossing the antimeridian (±180°).
basic_stats(graph, area_m2?) Map<String, float> of node / way / intersection counts + length total + (when area_m2 set) density measures per km². Mirrors osmnx.basic_stats.

Pure ring math runs on native types from std::core:

  • GeoPoly { points: ring }.area() — planar polygon area in m²
  • GeoPoly { points: ring }.centroid() — geometric centroid (geo?)
  • GeoPoly { points: ring }.mean_centroid() — arithmetic mean of vertices
  • GeoPoly { points: ring }.perimeter() — sum of segment lengths in m
  • GeoPoly { points: ring }.is_closed() — first / last vertex coincide
  • GeoPoly { points: ring }.interpolate(step) — resample along arc length
  • GeoPoly { points: ring }.contains(point) — point-in-polygon
  • GeoPoly { points: ring }.sw() / .ne() — bounding-box corners
  • a.distance(b) — great-circle distance
  • a.bearing(b) — initial compass bearing
  • geo::distance_to_segment(p, a, b) — clamped point-to-segment
  • geo::meters_per_deg_lon(lat) — longitude scale at a given latitude
  • atan2(y, x) — two-argument arctangent

Graph (OsmGraph)

Helper Notes
OsmGraph::create() Build an empty node<OsmGraph>
import_response(resp, src?) Merge transient → persistent. Four passes (nodes → ways → relation skeletons → relation bodies) so relation→relation refs resolve regardless of wire order.
nodes_in(bbox) Spatial scan via nodeGeo (bbox: GeoBox)
nodes_near(circle) Linear filter inside an expanded bbox (circle: GeoCircle)
nearest_node(circle) Linear nearest within circle.radius, clipped to the circle itself (not just its bbox)
nearest_edges(point, k, max_dist_m) Top-k ways whose nearest segment is within max_dist_m of point. Each OsmEdgeHit reports the way, segment index, distance, and projection. Sorted ascending by distance.
nodes_tagged(key, value?) Live read-only nodeIndex<int, node<OsmNode>>? from the nodes_tags inverted index — zero-copy, lazily iterable; null on no match. Do not mutate.
ways_tagged(key, value?) Live read-only nodeIndex<int, node<OsmWay>>? from the ways_tags inverted index; null on no match. Do not mutate.
relations_tagged(key, value?) Live read-only nodeIndex<int, node<OsmRelation>>? from the relations_tags inverted index; null on no match. Do not mutate.
nodes_tagged_any(keys) Union of nodes_tagged(k, null) across every key, deduped by osm_id. Folds N walks into one.
ways_tagged_any(keys) Union of ways_tagged(k, null) across every key, deduped by osm_id.
relations_tagged_any(keys) Union of relations_tagged(k, null) across every key, deduped by osm_id.
geometry_of(way) Array<geo> from a persistent way
way_ring(way) Semantic alias of geometry_of — reads more naturally for closed ways treated as polygon rings.
way_centroid_mean(way) Arithmetic mean of (lat, lng) over way->nodes. Single walk, no Array<geo> materialisation. null for empty ways.
way_centroid(way) Shoelace-weighted centroid via GeoPoly.centroid(). Allocates the ring; pair with way_area_m2 to amortise.
way_bbox(way) Tight axis-aligned GeoBox over way->nodes. Single walk, no Array<geo> materialisation. null for empty ways.
way_area_m2(way) Planar polygon area in m² via GeoPoly.area(). Returns 0 only for < 3 vertices; ≥ 3 vertices are implicitly closed, so an open way still yields a nonzero area — pass closed ways for a meaningful footprint.
building_orientation(way, mode) Facing vector (OsmOrientation?) for a building way. ridge/solar use the footprint (+ roof:* tags); street faces a tagged entrance/door node, else the nearest road within STREET_SEARCH_M metres, else the ridge perpendicular. null for < 3 vertices. Closed ways only (relations not yet supported).
building_entrance(way) First tagged entrance/door node location on the way (geo?), else null. Used by street-mode facing.
nearest_road(point, max_dist_m) Projection point of the nearest highway-tagged way within max_dist_m metres of point (geo?). Scans distance-sorted nearest_edges, skipping non-road ways.
boundary_bbox(rel) Union of way_bbox over every outer-role way member. null if no outer-way geometry. Inner-role members skipped.
boundary_centroid_mean(rel) Arithmetic mean of every vertex across every outer-role way. Single pass. null for relations with no outer-way vertices. Inner-role members skipped. Not shoelace-weighted — for that, glue rings via OsmGeometry::outer_rings_of_relation and feed each to GeoPoly.centroid().
ways_containing(target) Live read-only nodeIndex<int, node<OsmWay>>? of ways referencing target (reverse-ref index); null when none. Do not mutate.
truncate_to_bbox(box) Remove every node outside box and any way left with < 2 surviving nodes. Returns the number of nodes removed. Cascades reset cached length_m on partially-truncated ways.
truncate_to_polygon(poly) Same semantics as truncate_to_bbox but membership decided by GeoPoly.contains.
sample_points_on_ways(n, filter_key?, filter_value?, seed?) Length-weighted uniform random sample of n points along ways. seed non-null → reproducible. Returns Array<geo>.
add_way_lengths() Fill the cached length_m on every way. Returns the number of ways touched. Re-imports invalidate the cache automatically.
fetch_elevations(url?, batch_size?, api_key?) Batched HTTP elevation lookup against an Open-Elevation-compatible POST API. Writes each result to OsmNode.elevation. Returns the number of nodes updated.
to_geojson(include_untagged_nodes?) Serialise the graph as a GeoJSON FeatureCollection string. Closed ways → Polygon, open ways → LineString, nodes → Point. include_untagged_nodes defaults to false (drops bare way-vertices). Relations are omitted in this version.
remove_node(osm_id) Drop a node from every index (nodes_by_id, nodes_by_geo, nodes_tags, ways_by_node). Returns true iff present. Does not cascade into referencing ways/relations.
remove_way(osm_id) Drop a way and its reverse-refs in ways_by_node. Does not cascade.
remove_relation(osm_id) Drop a relation and its tag entries. Does not cascade.
clear() Empty every index + reset metadata

For element counts call .size() on the index you care about (g->nodes_by_id.size(), etc.).

Bounding boxes

Use GeoBox from std::core directly — it ships from_point, intersects, union, intersection, expand, center, width, height, split, plus the existing contains. Bridge to / from the Overpass OsmBounds wire shape via bounds.to_box() and OsmBounds::from_box(box).

Elements & members

Helper Notes
e.kind() / m.kind() Typed OsmElementType? from wire string
e.tag(key) / e.has_tag(key, value?) Tag map lookups

File readers

All entry points are in lib/osm/osm_readers.gcl (one file, three formats).

Helper Notes
OsmXml::read(path, filter?) full file → OsmResponse
OsmXml::import_into(graph, path, filter?) stream into persistent graph
OsmXml::open(path, filter?) chunked OsmReader; next() yields ~1000 elements
OsmPbf::read(path, filter?) full file → OsmResponse
OsmPbf::import_into(graph, path, filter?) stream into persistent graph
OsmPbf::open(path, filter?) chunked OsmReader; next() yields one PBF block
OsmChange::read(path, filter?) full diff → OsmChangeResponse
OsmChange::apply_into(graph, path, filter?) apply diff in-place
OsmChange::open(path, filter?) chunked OsmReader over the diff; iteration flattens create / modify / delete sections (use read / apply_into if section info is needed)
OsmReader.next() / .close() / .done() Chunked-iterator instance methods

Replication (OsmReplication)

Pure GCL; built on OsmChange::apply_into and the same Http<...> client OsmClient uses.

Helper Notes
OsmReplication::sync(graph, state, filter?, max_diffs?) walk every diff between state.sequence and the server’s latest; optional max_diffs caps the loop per call
OsmReplication::apply_file(graph, path, filter?) Offline path — delegates to OsmChange::apply_into
OsmReplication::diff_path(seq) Geofabrik diff layout for a sequence number — left-zero-padded 3-digit groups, e.g. 12345678"012/345/678.osc.gz"; leading group widens past 999_999_999

Geocoding (Nominatim)

Helper Notes
geocode(query, endpoint?) Up to 10 results
geocode_n(query, limit, endpoint?) Explicit limit
geocode_one(query, endpoint?) Best match as geo?
geocode_to_polygon(query, endpoint?) Best match’s outer polygon ring as Array<geo>?; null if no match or geometry isn’t a (Multi)Polygon.
geojson_first_ring(geojson) Lift a Polygon / MultiPolygon GeoJSON Geometry value (as parsed by Json<any>) to its first outer ring; null otherwise.
reverse(point, zoom?, endpoint?) Single best address-like result

Enum string bridging

Use std primitives — type::enum_name(OsmElementType::node) returns "node" (the enum field name), and type::enum_by_name(OsmElementType, "node") returns OsmElementType?. The enums carry no explicit payload; the field name is the wire string.

Settings

Live settings sit in a single module-level node, exposed through OsmSettings::current() / OsmSettings::configure(...) / OsmSettings::reset(). The fields and their baked-in defaults:

Field Type Default
default_endpoint String https://overpass-api.de/api/interpreter
default_nominatim_endpoint String https://nominatim.openstreetmap.org
default_elevation_endpoint String https://api.open-elevation.com/api/v1/lookup
user_agent String greycat-osm/1.0 (+https://greycat.io)
default_timeout duration 180_s
default_retries int 4
nominatim_min_interval duration 1_s (public Nominatim policy)
default_elev_batch int 100 (batch size for fetch_elevations)
bbox_pad_factor float 1.5 (spatial pad for nearest_edges)

Why a node rather than static fields: GCL static declarations on a type are read-only constants — direct assignment is a compile-time syntax error. The node-backed form keeps a single configure(...) entry point as the official override path.

Read from anywhere with OsmSettings::current().<field>:

var endpoint = OsmSettings::current().default_endpoint;

To override at app init, build a fresh OsmSettingsState and pass it to configure:

fn init() {
    var s = OsmSettings::current(); // grab the live snapshot (mutable local copy)
    s.user_agent = "my-app/1.0 (+ops@example.com)";
    s.default_endpoint = "https://overpass.kumi.systems/api/interpreter";
    s.default_timeout = 120_s;
    OsmSettings::configure(s);
}

OsmSettings::reset() reinstates the baked-in defaults. The library also exposes OsmSettings::citation() — the suggested attribution string for academic / report usage (greycat-osm + OSM contributors copyright).

Geometry accuracy

All planar math is local equirectangular centred on each ring’s vertex mean. Empirical accuracy of GeoPoly.area() / GeoPoly.centroid() on a sphere:

  • ring < ~200 m across, anywhere off the poles: < 0.1 %
  • ring < ~1 km across, |lat| < 60°: < 1 %
  • ring spanning > 5 km in lat, or |lat| > 75°: > 5 %
  • ring crossing the antimeridian (±180°): handled (longitudes unwrapped); same projection caveat as above

Rings that fail the accuracy budget should be routed through a real GIS layer. Great-circle helpers (geo.distance from std, GeoPoly.perimeter() / geo.bearing) use spherical formulae and are accurate everywhere except across the antimeridian.

geo itself quantizes to ~1e-7° (≈ 1 cm at the equator), so coordinates round-trip with sub-cm drift — geometry helpers’ epsilons are sized to absorb that.

Graph analytics quick-reference

// Bearing analytics (Boeing-style "city orientation"). The reducers take
// a way nodeIndex directly — pass graph->ways_by_id (whole graph) or a
// null-guarded ways_tagged(...) bucket; no intermediate array.
var hist = OsmStats::bearing_distribution(g->ways_by_id, 36);
var entropy = OsmStats::orientation_entropy(g->ways_by_id, 36);

// Intersection count + degree distribution:
var inters = OsmStats::intersection_count(g, 2);
var deg_hist = OsmStats::degree_distribution(g);

// Snap a probe to the road network:
var hits = g->nearest_edges(geo{33.8910, 35.5110}, 1, 50.0);
if (hits.size() > 0) {
    info("snapped to way ${hits[0].way->osm_id} at ${hits[0].projection}");
}

// Pre-compute and reuse way lengths:
g->add_way_lengths();
var stats = OsmStats::basic_stats(g, null); // reads cached length_m

// Truncate to an admin polygon obtained from Nominatim:
var poly_ring = Nominatim::geocode_to_polygon("Achrafieh, Beirut", null);
if (poly_ring != null) {
    g->truncate_to_polygon(GeoPoly { points: poly_ring!! });
}

// Export to GeoJSON for QGIS / Leaflet:
File::writeString("./out.geojson", g->to_geojson(false));

Limitations

  • No routing. Shortest-path (Dijkstra / A*) is not implemented; build it on top of nodeList<node<OsmNode>> if you need it.
  • OsmGraph::nearest_node / nearest_edges / nodes_near / nodes_in all walk nodes_by_geo linearly — adequate for city-scale data; planet-scale needs a native range-query backend.
  • nodes_tagged / ways_tagged / relations_tagged / ways_containing are O(matches) / O(refs) via inverted tag / reverse-ref indices (OsmTagIndex, ways_by_node) — no graph-wide scan. They return the live, read-only nodeIndex bucket (zero-copy, lazily iterable, null on no match) — never mutate it (it IS the graph’s index), and treat it as a live view that reflects later imports / clear(). The tag index keys (k, v) into a two-level nested nodeIndex, so values containing = (e.g. name=Foo=Bar) don’t conflate with other keys.
  • nodes_by_geo overwrites on quantization collision. Two OsmNodes whose locations round to the same ~1 cm geo quantum share an entry — the second insert wins for spatial queries. nodes_by_id still holds both. Real OSM data rarely hits this; if you do, project per-key into Array<node<OsmNode>> yourself.
  • No CRS / projection. See “Geometry accuracy” above.
  • to_geojson skips relations. Multipolygon assembly (outer + inner rings, role disambiguation) is non-trivial and deferred to a follow-up. Callers can lift a single relation’s outer rings via OsmGeometry::outer_rings_of_relation on the source OsmElement.
  • fetch_elevations depends on a public API. Open-Elevation is volunteer-operated and frequently slow / down. Production callers should override default_elevation_endpoint via OsmSettings::configure(...) to point at a self-hosted Open-Elevation instance or an Open Topo Data mirror. The wire format is identical (POST + {"locations":[...]}).