8.0.631-stable Switch to 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);

Overpass reports mid-execution failures — the server-side [timeout:N] or memory cap tripping after the query started — as HTTP 200 plus a remark starting with "runtime error" and an empty or partial elements array. query surfaces those as report.error ("overpass: runtime error ...") with elements emptied, so the retry-based helpers treat them as failures instead of silently keeping a truncated result. Other remarks are non-fatal notices and pass through on OsmResponse.remark.

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 whose message embeds the serialized thrown object (OsmQueryError{message:"invalid Overpass key: …",code:…}), so neither the typed code nor a bare message prefix is recoverable in a catch block — substring-match a stable fragment instead (ex.message.contains("invalid Overpass key"), "invalid Overpass value", "invalid Overpass area iso", "from_point: 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?> vars at module level (module-level node generics must be nullable — a non-nullable node<OsmGraph> module var is a compile error) 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 and transport errors are both logged via warn — the log line’s status code vs. exception message distinguishes “no match” (a 200 with empty content), “service unavailable”, and a network failure. Both return null / empty array to the caller, so watch warn-level logs (not just error) if you alert on Nominatim outages.

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). Paths are UTF-8 on every platform, including Windows. A UTF-8 BOM at the start of an XML / osmChange file is tolerated.

// 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);
if (stats.error != null) {
    // import_into reports failure via stats.error — it never throws.
    error("import failed: ${stats.error}");
} else {
    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

Error reporting (shared by all three formats)

Each entry point surfaces failures explicitly — none of them silently truncates:

  • import_into / apply_into never throw for data errors: check stats.error (the counters then reflect partial progress).
  • read never throws for data errors either: all three formats set the response’s error field — OsmResponse.error for OsmXml / OsmPbf (open failure, or e.g. "OsmXml::read: malformed XML near byte N"), OsmChangeResponse.error for OsmChange — and keep the elements parsed before the failure. An empty response with error == null therefore genuinely means an empty file (for a change feed: a real “no changes” answer).
  • reader.next() throws on a mid-stream IO / parse error and drops the partial chunk; the reader is released, so a follow-up next() returns null. From a successfully-opened reader, a plain null therefore always means clean EOF.
  • open() on an unopenable file returns an already-closed reader — done() is true and the first next() is null (OsmPbf::open additionally warn-logs the reason).

On the chunked readers the filter is re-resolved once per chunk (per block for PBF), so mutating the OsmFilter you passed to open between next() calls takes effect on the next chunk.

PBF format notes

  • Block compression: raw and zlib only. bzip2 / lz4 / zstd / lzma blobs fail hard with "osm_pbf: unsupported Blob compression (<codec>)" instead of silently decoding as empty blocks. Geofabrik / planet extracts use zlib and are unaffected; re-encode exotic files with osmium cat -f pbf,pbf_compression=zlib.
  • Full-history extracts (.osh.pbf) are rejected: any required_features entry outside OsmSchema-V0.6 / DenseNodes (e.g. HistoricalInformation) fails with "osm_pbf: unsupported required feature ..." rather than importing deleted element versions as live geometry.
  • Legacy non-dense Nodes groups (ancient osmosis output) fail with "osm_pbf: legacy non-dense Nodes group not supported" instead of a silent zero-node import.
  • No audit meta from PBF. The decoder skips Info / DenseInfo, so timestamp / version / changeset / uid / user stay null on PBF-read elements — OsmXml and OsmChange populate them from the same data.

osmChange (.osc / .osc.gz)

OsmChange::read returns an OsmChangeResponse that splits elements into creates / modifies / deletes arrays. Create / modify elements carry the audit meta (timestamp / version / changeset / uid / user) parsed from the diff; <delete> entries stay id + type only, per the OsmChange spec. Elements outside any section (malformed producer output) are dropped, never misfiled as creates.

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). Within one diff, document order is honored: pending creates / modifies are flushed before each <delete> executes, so a delete followed by a later re-create / undelete of the same id inside the same diff correctly ends with the element present. Across diffs, order still matters — apply them in sequence order.

var stats = OsmChange::apply_into(graph, "./diffs/123.osc.gz", null);
if (stats.error != null) {
    error("apply failed: ${stats.error}");
} else {
    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. Downloaded diffs land in the system temp directory — the first non-blank of TMPDIR / TMP / TEMP, falling back to /tmp — so sync also works on Windows.

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 @volatile native handle — it owns a C file handle and parser scratch on the host allocator, so it cannot survive serialization and the runtime rejects persisting it (rather than silently storing a shell that reloads dead). Always close() (idempotent) or let the native finalizer collect it; either path releases the FD. next() throws on mid-stream IO / parse errors — see “Error reporting” above.

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 (retries fire on ANY error — there is no transient-vs-permanent distinction, so a permanently-failing request sleeps through the full exponential backoff schedule), and dedupes elements that appear in two adjacent tiles (keyed by (kind, id)). A failed tiled report is NOT empty: error names the failing tile and elements keeps the partial deduped results of the tiles fetched before it — gate on report.error, never on elements being non-empty.
  • 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. remark carries Overpass status notes ("runtime error" remarks are surfaced by OsmClient::query as report errors); error is set by OsmXml::read / OsmPbf::read on open / mid-stream failure
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, plus error? set on open / mid-stream failure (buckets keep partial progress)
OsmChangeStats @volatile OsmChange::apply_into / OsmReplication::* outcome (created, modified, deleted, skipped, duration, error?)
OsmReader @volatile Chunked-iterator handle (file descriptor + parser state; non-persistable — the runtime rejects storing it). 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; retries ANY error, not just transient ones
fetch_buildings_tiled(bbox, rows, cols, area_iso?, endpoint?) Tile-aware bulk fetch with dedupe; on failure keeps partial elements + error naming the failing tile
fetch_from_point(point, dist_m, key, value?, types, area_iso?, endpoint?) OsmQuery::from_point (bbox-from-point + tag filter) → query; 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 extending dist_m metres from point in every direction (a square of side 2 * dist_m, osmnx-style) + 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 truncated to whole seconds, clamped to ≥ 1 — a sub-second duration emits [timeout:1])
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>>. Closed member rings pass through; open outer fragments (one ring split across many member ways — the admin-boundary norm) are chained by shared endpoints into closed rings; fragments that can’t close are dropped rather than returned as bogus open “rings”
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 edge-orientation analysis: [ridge_bearing_deg, elongation, confidence] (empty array on degenerate input). The ridge is usually — not always — the long axis: concave (comb / E-shaped) footprints can yield the short axis, and elongation can’t tell the cases apart. 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.

Lower-level native primitives are also public for callers who want them directly: OsmGeometry::polyline_length_m, polyline_centroid_mean, project_onto_segment, and bearing_histogram (num_bins < 1 returns an empty array; num_bins > 100000 throws — allocation cap).

The orientation math backing orientation_of is public too, usable standalone: hemisphere_optimal(lat), normalize360(deg), angular_distance(a, b), equator_pick(cand_a, cand_b, lat), parse_roof_direction(value) (numeric or 16-point cardinal roof:direction → compass degrees), solar_tag_azimuth(lat, tags?), solar_azimuth(ridge, lat, tags?), and make_orientation(poly, center, azimuth, mode, confidence); on the graph side OsmGraph::roof_tags(tags) extracts the roof:* tags that solar mode reads. Three module-level fns are also visible: osm_build_elevation_body(nodes) (builds the fetch_elevations request body — deliberately public so it is testable offline) plus osm_polyline_length(ns) / osm_way_length(w), internal helpers that stay non-private only because they are shared across the lib’s files — treat those two (and the OsmTagIndex<T> index type behind *_tagged) as implementation detail, not stable API.

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 edge direction (usually, not always, the long axis — see footprint_ridge), 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 in two shapes. The nodeIndex-taking reducers walk only the supplied index — 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 — in one lazy pass with no collection-sized intermediates (exception: bearing_distribution collects the per-way bearings into an array for the native histogram). The four graph-taking reducers (degree_of, degree_distribution, intersection_count, basic_stats) read the graph’s own indices (ways_by_node, ways_by_id, …) directly.

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. Ways whose endpoints coincide — closed loops (roundabouts, footprints) and degenerates — are excluded from BOTH sums (Boeing’s methodology), so loops no longer inflate the ratio. 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 the way’s vertices, antimeridian-safe via GeoPoly.mean_centroid(). Allocates the ring. 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 the way’s vertices. Allocates the ring. 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 the full distance-sorted nearest_edges batch, skipping non-road ways — dense non-road surroundings (footprints, fences) can’t crowd an in-range road out.
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 — including node moves, which reset length_m on every way containing the moved node.
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; failures set resp.error, partial elements kept
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; failures set resp.error, partial elements kept
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; failures set resp.error, partial buckets kept
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. next() throws on mid-stream IO / parse errors (partial chunk dropped); null = clean EOF

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);
}

configure(s) stores a field-copy snapshot of s, not the caller’s reference — mutating s after configure(s) does not touch the live settings (the mirror of the defensive copy current() makes on the read side); call configure again to publish further changes.

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; one lazy pass.
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 (std TextWriter):
var w = TextWriter<String> { path: "./out.geojson" };
w.write(g->to_geojson(false));
w.flush();

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 query nodes_by_geo through the nodeGeo Morton-order range slice (nodes_by_geo[sw..ne]) — O(range slice), not O(total nodes). The Z-order slice is a superset of the bbox (the curve leaves and re-enters the rectangle, occasionally by a lot for elongated or antimeridian-adjacent boxes), and each candidate in the slice still pays a linear contains / distance filter — plus, for nearest_edges, a per-candidate way-segment scan and a final sort.
  • 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":[...]}).