In this page
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_placeosmnx-style entry points. - A type-safe Overpass-QL builder with input-validation guards.
- Native ring math on
std::coregeo/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/OsmRelationinnodeList/nodeIndex/nodeGeocollections, enabling spatial queries, durable storage,nearest_edgesfor 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_statsreducer (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-shotread, direct-to-graphimport_into/apply_into, chunkedOsmReader), plus anOsmReplicationclient 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 whenendpointisnull.- Use the persistent OSM graph instead. Once a region has been imported (via
OsmGraph::import_responseor one of the streaming readers), thenodeGeoindex 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
OsmGraphis anode<>— share refs across workers freely; mutating methods (import_response,clear) are transactional per call.OsmClientcalls are blocking HTTP. Overpass caps at ~180 s per query and rate-limits aggressive callers. For country-sized fetches,fetch_buildings_tiledis the supported pattern: it splits the bbox, retries each tile up toOsmSettings::current().default_retriestimes, 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::core — geo, 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 OSMroof:direction/roof:shape=flat/roof:orientationtags, else the ridge-perpendicular nearest the equator, else the hemisphere optimal.street— faces a taggedentrance/doornode, 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 verticesGeoPoly { points: ring }.perimeter()— sum of segment lengths in mGeoPoly { points: ring }.is_closed()— first / last vertex coincideGeoPoly { points: ring }.interpolate(step)— resample along arc lengthGeoPoly { points: ring }.contains(point)— point-in-polygonGeoPoly { points: ring }.sw()/.ne()— bounding-box cornersa.distance(b)— great-circle distancea.bearing(b)— initial compass bearinggeo::distance_to_segment(p, a, b)— clamped point-to-segmentgeo::meters_per_deg_lon(lat)— longitude scale at a given latitudeatan2(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_inall walknodes_by_geolinearly — adequate for city-scale data; planet-scale needs a native range-query backend.nodes_tagged/ways_tagged/relations_tagged/ways_containingare O(matches) / O(refs) via inverted tag / reverse-ref indices (OsmTagIndex,ways_by_node) — no graph-wide scan. They return the live, read-onlynodeIndexbucket (zero-copy, lazily iterable,nullon 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 nestednodeIndex, so values containing=(e.g.name=Foo=Bar) don’t conflate with other keys.nodes_by_geooverwrites on quantization collision. TwoOsmNodes whose locations round to the same ~1 cmgeoquantum share an entry — the second insert wins for spatial queries.nodes_by_idstill holds both. Real OSM data rarely hits this; if you do, project per-key intoArray<node<OsmNode>>yourself.- No CRS / projection. See “Geometry accuracy” above.
to_geojsonskips 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 viaOsmGeometry::outer_rings_of_relationon the sourceOsmElement.fetch_elevationsdepends on a public API. Open-Elevation is volunteer-operated and frequently slow / down. Production callers should overridedefault_elevation_endpointviaOsmSettings::configure(...)to point at a self-hosted Open-Elevation instance or an Open Topo Data mirror. The wire format is identical (POST +{"locations":[...]}).