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);
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 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 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_intonever throw for data errors: checkstats.error(the counters then reflect partial progress).readnever throws for data errors either: all three formats set the response’serrorfield —OsmResponse.errorforOsmXml/OsmPbf(open failure, or e.g."OsmXml::read: malformed XML near byte N"),OsmChangeResponse.errorforOsmChange— and keep the elements parsed before the failure. An empty response witherror == nulltherefore 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-upnext()returnsnull. From a successfully-opened reader, a plainnulltherefore always means clean EOF.open()on an unopenable file returns an already-closed reader —done()istrueand the firstnext()isnull(OsmPbf::openadditionallywarn-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 withosmium cat -f pbf,pbf_compression=zlib. - Full-history extracts (
.osh.pbf) are rejected: anyrequired_featuresentry outsideOsmSchema-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
Nodesgroups (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, sotimestamp/version/changeset/uid/userstaynullon PBF-read elements —OsmXmlandOsmChangepopulate 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
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 (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:errornames the failing tile andelementskeeps the partial deduped results of the tiles fetched before it — gate onreport.error, never onelementsbeing 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::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. 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 — seefootprint_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 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 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 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 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_inquerynodes_by_geothrough thenodeGeoMorton-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, fornearest_edges, a per-candidate way-segment scan and a final sort.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":[...]}).