Packer & routing
Two jobs bracket the device's own work. Packing turns raw OpenStreetMap data into a styled .obcm map — a heavy job, run once on a computer, and (as of v8) it also bakes a routable navigation graph into the map — one that v9 makes bike-type-aware. Routing is the lighter on-device work: turning a GPX you upload into a navigable .obcr, map-matching your live position onto it as you ride, and — new this iteration — computing a route to a POI on the device itself over that baked graph. So "routing" here is mostly following a line you brought, with a memory-bounded bit of pathfinding when you ask the device to reach a POI on its own.
The packer (obc-pack) lives in the same Rust workspace as the device firmware and depends on the same obc-reader, so the program that writes the format and the program that reads it can never disagree about a byte.
Packing a map
The pipeline is a straight line from an .osm.pbf extract to a finished .obcm. Two stages carry the weight — ingest and the per-LOD build — and the rest are quick.
Styling: first match wins
What a feature is — and whether it's kept at all — comes from a config.json. It's an ordered map of tag_key → value → style, and a way is styled by the first rule (in document order) whose tag it carries. Style IDs are assigned 1-based in that same document order; the config never names them.
z_index), a line weight, a drop-priority, a min_lod (the zoom tier below which the feature isn't included), and — since v10 — a line_style and an optional secondary colour color2 for dashes, casings, stripes and outlines. These become the style table in the file, and the colours resolve through the very same color_fn the UI uses.pub fn get_style(&self, tags: &HashMap<&str, &str>) -> Option<&FeatureStyle> {
for (tag_key, by_value) in &self.features { // walked in document order
if let Some(val) = tags.get(tag_key.as_str()) {
// exact value first, then the category's "*" catch-all
if let Some(style) = by_value.get(*val).or_else(|| by_value.get("*")) {
return Some(style); // first match wins
}
}
}
None // unstyled → dropped
}
Within a tag_key, the value "*" is a catch-all: an exact value match still wins, but any other value that key carries falls back to the "*" rule. So building → { warehouse: …, "*": … } gives warehouses their own style and paints every other building=* with the catch-all — without enumerating OSM's ~50 building values by hand. The catch-all is an ordinary rule (it takes one style ID like any other), so it's purely a packer-side convenience; the file format and the device never know it existed.
Ingest: two passes, then assemble
OSM is nodes, ways and relations, stored in that order. The ingester reads the .pbf twice. Pass 1 builds a node id → coordinate store and notes which area relations exist (lakes-with-islands, multi-part forests). Pass 2 turns ways into lines and polygons — and captures the geometry of any way a relation needs. Then each relation's member ways are assembled into a polygon-with-holes. (Cropping to a box adds a third pass in front of these — below; several regions are read by these same passes — below.)
build_area, which sorts member rings into outers and holes by geometry — so a lake with an island comes out as a polygon with one interior ring, ready for the holes encoding. A closed way is a polygon only if its tags say it encloses an area; a closed highway loop stays a line, never also a filled blob.Cropping to a box
You rarely want a whole country. --bbox W,S,E,N crops the source during ingest, in a pass 0 that reads only ids: the nodes inside the box, the ways touching one of them, and — the part that matters — the nodes those ways still need outside the box.
That last clause is the whole design. The naive filter is "drop everything outside the box", and it fails in a way you'd only notice on the road. A way that leaves the box would be missing node positions, and the ingester drops a way it cannot fully resolve rather than guess at half of it — so every road crossing the boundary would vanish back to its last node inside. The map would fray inwards from its own edge, and the navigation graph would lose real exits: not a road drawn short, an exit the router doesn't know exists.
So a way is kept whole or not at all. An edge ends where the way ends, a little outside the box, rather than at an arbitrary vertex on the box edge — no phantom junction, no invented dead-end at the border. Relations follow the same all-or-nothing rule they already follow: one member way missing and the relation is dropped, never assembled from the survivors into a shape that was never there.
Two consequences worth expecting. The finished map's header bounding box is always a little wider than the box you asked for, because it's measured from the packed content and complete ways stick out — which is why an extract box must never be re-derived from a packed map's header, or it ratchets outward on every re-pack. And peak memory tracks the box, not the source file: the id sets and the coordinate store only ever hold what the box selected, so cropping a 500 MB country costs about what packing the resulting small map costs.
This is exactly the strategy osmium extract calls complete_ways, and it's deliberately the same one — down to the integer grid the edge test runs on — so the packer grew a crop rather than the toolchain growing a second C++ dependency.
Merging several regions
A tour that crosses a border needs two extracts, and the two genuinely overlap. Geofabrik cuts its regions along administrative boundaries and completes the ways that cross them, so every bridge, river and border road exists in both files under the same OSM ids. Merging is therefore not concatenation: it is deciding, object by object, which copy is the real one.
That decision used to belong to osmium merge — the packer's second C++ dependency. It now happens inside the passes already described: every pass reads every file, and the results are folded together afterwards, under two rules.
On a duplicate id, the first file listed wins — the whole object, not a blend. The tie-break is decided on the (type, id) alone. This matters because two extracts downloaded a week apart can hold two versions of the same object; osmium merge keeps both (it is built to handle history files), and the packer then drew both — one building rendered twice where somebody had retagged it between the two downloads. Picking a whole-object winner by id makes that impossible.
The survivors come out in ascending id order, per type — the order a single merged, sorted file would have produced. Feature order decides which quadtree chunk a feature lands in and therefore the packed bytes, and on overlapping regions with no version skew the native merge reproduces the external chain's output byte for byte.
Memory tracks the box, not the source: nothing is re-written to disk and nothing holds a whole merged region resident, where the external chain's node-location index was sized by the largest node id in all of OSM — a two-region boxed build peaked around 2.3 GB there versus ~340 MB reading the regions in place, at about the same wall clock (the files are read in parallel).
Land and sea
OSM ways draw the coast, but not the sea or the land fill. Those come from a separate global dataset of land polygons, clipped to the map's bounding box and added as features styled natural.land. The sea needs no geometry at all: it's the backdrop the renderer clears to before drawing, and land is simply painted on top.
That dataset is ~950 MB, downloaded and unpacked once into a shared cache that every host points at — the CLI, the dev server and the desktop app between them keep one copy, not three. Both steps are the packer's own code rather than a curl and an unzip it hopes are installed: it is the same binary inside a shipped app, where neither is a safe assumption and where a subprocess could not be cancelled or asked how far along it is.
Extracting POIs
The same OSM extract carries more than geometry. Amenities a bikepacker actually looks for — water, campsites, lodging, resupply, pharmacies, bike shops — are tagged on nodes and areas the geometry pipeline would otherwise style-and-forget. A separate stage harvests them into the map's POI section, where the device browses them by category. It's config-free on purpose: the tag → category mapping is hardcoded in the packer (a locked decision), so packing the same extract always yields the same POIs.
key=value table (first match in table order wins, the same rule as the style config), and its name is folded to printable ASCII and capped at 24 bytes. The last step matters because OSM double-maps: a drinking-water node sitting inside a same-tagged area, an entrance node beside a campsite polygon. Two candidates of the same category within ~50 m collapse to one, and the winner is chosen by priority — a node (a real placed point) beats a derived centroid, a named POI beats an unnamed one.Why fold names at all? The OBCM Name field is a fixed 24-byte, printable-ASCII slot: fixed-width keeps records seekable, and one byte per character keeps the budget a predictable 24 glyphs (a raw UTF-8 ö is two bytes). So rather than store variable-width UTF-8, the packer transliterates at pack time: German umlauts get their proper digraphs (ä → ae, ß → ss), the rest of Latin strips to its base letter, and anything genuinely unmappable (CJK, Cyrillic, Greek) becomes a word break rather than gluing neighbours together. (The device font itself covers Latin-1 + Latin Extended-A, so phone-supplied route and ride names render their umlauts directly on-glass — it's only these fixed-width packed POI names that fold.) A name that folds away to nothing is stored as unnamed, and the device falls back to the subtype's label ("Spring", "Bakery"). The 24-byte cap is a device-row width, not a storage worry — POI bytes are noise next to geometry.
pub const POI_TABLE: [PoiKind; 18] = [
kind(1, "amenity", "drinking_water"), // → Water / "Drinking water"
kind(2, "natural", "spring"), // → Water / "Spring"
kind(5, "tourism", "camp_site"), // → Campsite
kind(13, "shop", "supermarket"), // → Resupply / "Supermarket"
// … 18 rows, ids append-only — the subtype id is normative (OBCM_Spec §7.4)
];
The subtype ids are normative and shared: the packer owns only the OSM key=value half of the table, while each subtype's category and fallback label live once in obc-formats's POI table — the same table the device reads through obc-reader — so the two crates can't drift, and a pinning test asserts every row agrees. The extracted, deduped, name-folded POIs are handed to the serializer, which builds the per-category quadtrees of the POI section.
Parsing opening hours
A POI carries one more thing worth harvesting: when it's open. OSM stores that in the opening_hours tag — a compact grammar like Mo-Fr 08:00-18:00; Sa 09:00-13:00; PH off. Parsing that grammar is a host job that never runs on the device: the microcontroller has no room for a date library and no reason to re-derive the same answer every frame. So the packer parses opening_hours once, at pack time, into the fixed 29-byte weekly schedule the device reads with a single array lookup.
The parser is a deliberate subset, not a full opening_hours engine — the real data (town shops, campsites) exercises a small corner of the grammar, and a full engine would drag a time model into a build tool that has no clock. It handles weekday ranges and lists (Mo-Fr, Mo,We,Fr), HH:MM-HH:MM intervals (including a split lunch, two per day), 24/7, off/closed, bare time-only rules that apply every day, and overnight wrap. Times are rounded to the nearest quarter-hour with the same round-half-to-even convention the packer uses for coordinates. Anything it can't model it drops and flags rather than guessing — it never invents hours that aren't there.
Apr-Oct: …) carries a date selector the weekly blob can't express, so the packer bakes a representative in-season week and sets the seasonal flag — the v1 device ignores it, but the bit is there for a future season-aware pass. A rule the subset genuinely can't model — a public-holiday PH clause, a sunrise/sunset time, or a third interval on a day — is dropped and the truncated flag set, so the device knows the schedule is partial rather than wrong. Finally every schedule is deduplicated into a shared pool: because a whole town's shops so often keep the same hours, the pool stays tiny, and a POI with no parseable hours (the common case) costs nothing but a 0xFFFF sentinel in its record.The subset grammar, the flag semantics, and the quarter-hour encoding live in obc-pack/src/hours.rs; the pooled bytes are described in OBCM_Spec.md §7.5. The device end — turning a pooled blob into today's hours and an open-now badge — is the POI detail view.
Building the navigation graph
The map so far is geometry the device draws. To let the device route — compute its own way to a POI — the packer builds one more thing the raw data doesn't contain: a navigation graph. Highways in OSM are ways, and the geometry pipeline turns them into styled polylines the moment it resolves their coordinates — dropping the node ids as it goes. But those node ids are the topology: two roads that share an OSM node meet there. This stage keeps the node ids for routable ways and recovers the graph from the shared ones. It's serialized into the map's navigation-graph section, and it's always built — a config-free, always-present section like the POIs, so packing the same extract always yields the same graph (there's no toggle to forget).
highway, access, bicycle, motorroad) — never the style config, so a road can be drawn but not routable, or the reverse. Motorway is always out; trunk is out unless tagged bicycle=yes; access=no|private, bicycle=no|use_sidepath, and motorroad=yes are hard excludes; footway and steps stay in because it is legal to walk a bike there — preference is the profiles' job. Junction detection is pure counting: a node touched by two-or-more routable ways, or any way's endpoint, is a junction. Ways split at their junctions into edges with junction-free interiors, and duplicates collapse on (unordered endpoint pair + geometry + way-kind), so two genuinely different roads between the same pair both survive. Cost is great-circle length in metres, summed with the same helper the route format uses. A hygiene pass prunes islands (components under min_component_edges, default 50) so the device can't snap a rider onto an unroutable islet, and long edges split at synthetic junctions so every field fits the §8 record's int16/uint16.A way is routable exactly when it can be classified — the same pass that decides legality also computes the edge's way-kind byte (its highway + surface class, below), so is_routable is just classify(tags).is_some():
pub fn is_routable<'a, I: IntoIterator<Item = (&'a str, &'a str)>>(tags: I) -> bool {
classify(tags).is_some()
}
/// The packed way-kind byte, or None when the way is bike-illegal.
pub fn classify<'a, I: IntoIterator<Item = (&'a str, &'a str)>>(tags: I) -> Option<u8> {
// ... read highway / surface / bicycle / access / motorroad from tags ...
if matches!(access, Some("no" | "private")) { return None; } // hard excludes
if motorroad == Some("yes") { return None; }
if matches!(bicycle, Some("no" | "use_sidepath")) { return None; }
let hclass = match highway? {
"trunk" | "trunk_link" if bicycle == Some("yes") => 13, // else excluded, like motorway
other => highway_class(other)?, // None ⇒ not routable (motorway, …)
};
Some((surface_class(surface) << 5) | hclass) // way_kind = surface<<5 | highway
}
A real pack run logs the graph next to the POI counts, so a glance at the build output confirms it's there and plausibly sized:
nav graph: 12874 nodes, 15903 edges, 8421.6 km
The in-memory build — the way-kind classification, the bike-legality filter, junction detection, edge split and dedup, island pruning, and great-circle lengths — lives in obc-pack/src/nav.rs; turning that graph into the tiled, chunked §8 section (the node quadtree, the inline-adjacency records, the byte-addressed edge pool, and the densify + long-edge split that keep every record inside one chunk) is the serializer's job, described in OBCM_Spec.md §8. What the device does with it — snap, profile-weighted A\*, emit — is the router seam.
Weighting the graph: bike profiles
The graph so far is bike-legal but undifferentiated: every edge costs its metres, so the device would route a road bike down a muddy singletrack if it were a few metres shorter. What makes an MTB route differ from a road route — why your MTB route differs — is two more things the packer bakes in. Each edge carries a way-kind byte, and the section opens with a small table of bike profiles; on the device, A\ multiplies each edge's raw metres by the chosen profile's weight for that edge's way-kind, so "shortest" becomes "cheapest for this bike*."
Way-kind is one byte per edge, way_kind = (surface_class << 5) | highway_class — a 5-bit highway class (0 cycleway, 1 path, 2 track, 3 footway, … 10 tertiary, 11 secondary, 12 primary, and 13 trunk_cycl for a bike-legal trunk) and a 3-bit surface class (paved, compacted, gravel, dirt, rough, cobbles, grass, plus unknown). Both tables are locked and config-free — the same OSM extract always yields the same bytes — and they are the single vocabulary profiles are written against. The full canonical table is OBCM_Spec.md §8.6 (mirrored from the one source of truth, nav.rs); the device never sees a raw OSM tag, only this byte.
A profile is a display name plus a multiplier for every highway class and every surface class, stored in 1/16 fixed-point (so 16 = 1.0×, and 0 means forbidden — that class is dropped from the profile's graph entirely). The map carries 1–8 of them (the default pack ships four); the device's effective weight for an edge is (highway_mult × surface_mult) >> 4. Here are the four default profiles' highway weights for a handful of classes (the preset has the rest, plus the surface axis):
| highway class | Road | Gravel | MTB | Touring |
|---|---|---|---|---|
| cycleway | 1.0 | 1.1 | 1.3 | 1.0 |
| primary | 1.8 | 2.2 | 3.5 | 2.6 |
| track | 6.0 | 1.2 | 1.0 | 1.6 |
| path | 7.0 | 1.5 | 1.0 | 2.0 |
| steps | forbidden | forbidden | 3.0 | 6.0 |
Read a column and you can predict the routing. Worked example: suppose a rider can reach the same destination two ways — a 1 km stretch of primary road, or a 2 km cycleway that loops around it (both paved, so each profile's surface weight scales both sides equally and drops out of the comparison). Multiply length by the highway weight:
- Road — primary
1000 × 1.8 = 1800, cycleway2000 × 1.0 = 2000. Road takes the primary (1800 < 2000): a road cyclist would rather ride 1 km of quiet main road than 2 km out of the way. - MTB — primary
1000 × 3.5 = 3500, cycleway2000 × 1.3 = 2600. MTB takes the cycleway (2600 < 3500): to the MTB profile the primary is so heavily penalised that the 2× detour is still cheaper.
Same two roads, same start and finish, opposite choice — that difference is entirely the profile, and you could have called it from the table above.
One rule constrains every profile: no weight below 1.0× (a non-zero multiplier is always ≥ 16). The on-device A\ uses a great-circle heuristic, which is only admissible — only safe to trust — if no edge can cost less than its straight-line distance. A weight under 1.0× would make some edge cheaper than the crow flies and quietly break the ε bound. So the packer rejects a config with a non-zero weight below 1.0 (naming the A\ bound in the error), and the reader clamps one up to 1.0× defensively. Which is what keeps ε meaningful: the router's f = g + ε·h with ε = 1.3 returns a path at most 1.3× the cheapest route under the profile — not the geometrically shortest, the cheapest once your bike's weights are applied. (When even the tight 1.3× bound exhausts the device's fixed search table, ε escalates — 1.3 → 2.0 → 3.0 — to reach farther in the same memory; the bound is then the successful rung's ε. That range mechanism lives with the router seam.)
Which profile the device uses is a single Bike-type setting — a bare index into the loaded map's profile table, persisted across reboots. Pick "MTB" and every plan re-weights accordingly; the created-route overview shows the profile it used. If the setting points past a particular map's profile count (a smaller map, a stale setting), the router falls back to profile 0 and the UI honestly shows profile 0's name rather than a profile the map doesn't have.
Profiles are the one part of the routing graph that is configurable (the topology is not). The web builder's advanced editor has a Bike-profiles panel: one row per way-kind class, a multiplier cell per profile, and a forbidden toggle for the 0 case — schema-driven from the same class vocabulary above, and it enforces the ≥ 1.0× floor in the editor so a config that the packer would reject can't be exported in the first place. Like every other field, it round-trips to a plain CLI config.
Building the LOD pyramid
The file is a pyramid of detail levels, and the packer builds each one independently. Two knobs from the config drive it: every feature's min_lod (the coarsest tier it's allowed into) and each tier's simplify tolerance. So the country tier holds a handful of feature types, heavily simplified; the street tier holds everything at (near-)full detail. The presets pick each tolerance pixel-accurately: one pixel at the finest scale the tier is drawn at, which is the next finer tier's max_mpp ceiling. The finest tier has no finer fallback, but still carries a small sub-pixel simplify (0.5–2 m in the presets) — enough to shed OSM's redundant, GPS-jitter vertices, which dominate the renderer's point budget at street zoom, with no visible change.
An optional third knob, min_area_px, declutters the coarse tiers: after simplify, a polygon whose projected area falls below that many square pixels (measured at the tier's finest on-screen scale) is dropped, so a region's worth of sub-pixel forest and landuse slivers stops crowding the render's point budget. It never touches the finest tier, and lines are left alone — an OSM way is stored as many short segments, so an area test would drop a road's shortest links and leave it holed. The same threshold also trims sub-pixel holes out of the polygons it keeps: a hole smaller than a pixel is painted straight over anyway, so dropping it is invisible yet frees a ring plus its vertices.
A fourth knob, merge_fills, attacks redundancy. Rural OSM is wall-to-wall farmland and meadow parcels, each mapped as its own way, and on a 64-colour panel many landuse types collapse onto the same green — every shared parcel boundary stored twice and drawn as two polygons even though the screen shows one flat fill. With the flag on, the packer unions every polygon whose style renders pixel-identically (same z_index, color, priority, and no color2 — an outline's walls must not be dissolved) into one shape per tier. The union runs before simplify: adjacent parcels share boundary nodes, so unioning first dissolves them exactly, where simplifying first would nudge each copy independently and leave hairline cracks along every seam.
A fifth knob, merge_lines, is the same idea for lines — and it targets the budget a dense frame runs out of first. OSM splits one continuous road, river, or railway into many ways, each packed as its own line feature; since a line's whole look lives in the style table, same-styled fragments that meet end-to-end draw identically to one polyline. The packer stitches them into maximal polylines per tier, stopping at genuine junctions so distinct roads never fuse; each join reclaims one span and one ring, and span/ring budgets saturate long before the point budget on a dense map. Two lines stitch only if their styles agree on the full render identity (z_index, color, weight, priority, dashed, color2), and stitching runs before simplify so a merged run's now-interior junction vertices simplify away too. The one visible difference is an improvement: a dashed or cased line's pattern runs continuously across a former join instead of restarting.
All three knobs are off by default, so the packed bytes are unchanged unless you ask for them.
for i in 0..lods.len() { // coarse (0) → fine
let tol = lods[i].simplify_m / M_PER_DEG;
let cull_mpp = lods.get(i + 1).and_then(|l| l.max_mpp); // finest tier: None → never cull
let level: Vec<(u8, Geom)> = features
.par_iter() // rayon — one GEOS context per thread
.filter(|f| f.min_lod <= i) // the LOD gate
.filter_map(|f| {
let g = simplify(&f.geom, tol);
let too_small = cull_mpp // drop sub-min_area_px polygons (lines: never)
.is_some_and(|mpp| footprint_below(&g, mpp, lods[i].min_area_px));
(!too_small).then(|| (f.style_id, g))
})
.collect(); // order preserved
let tree = build_lod(level, global_bbox, chunk_size); // this tier's quadtree
serialize_and_stream(tree); // write to disk, then drop
}
The quadtree: packing geometry into chunks
Within a tier, features are bucketed into a quadtree over the global bounding box. A node holds every feature reaching it; if their combined packed size — 12 + point_count·4 bytes each — fits the chunk size it becomes a leaf, otherwise it splits into four (NW · NE · SW · SE), hands each child the features it reaches, and recurses. A feature that straddles a child boundary is clipped to each child's box. The four child subtrees are built in parallel — they share no state, and only plain geometry (never a live GEOS handle) crosses a thread — which is what keeps the per-LOD build, otherwise the packer's heaviest stage, off the critical path.
let total: usize = feats.iter().map(|f| 12 + pt_count(&f.geom) * 4).sum();
if total <= chunk_size || !splittable {
return Node::Leaf { bbox, features: feats }; // fits the chunk → a leaf
}
// too big → split NW/NE/SW/SE, clip straddlers into each child, recurse in parallel
let (nw, ne, sw, se) = distribute_to_quadrants(feats, bbox);
rayon::join(|| (build(nw), build(ne)), || (build(sw), build(se)))
That this is the same quadtree the device walks closes the loop with the other pages: the packer writes it, the format stores it as a flat u32 array, and the renderer walks it to cull.
The builder
Everything above hides behind an app that turns "I want a map of the Black Forest" into an .obcm — pick an area on a map (whole Geofabrik regions, or a drawn box the sources are cropped to), pick a style, build, and then either take the file or put it on the device. Four ideas shape it:
- Presets over knobs. The main page offers complete style presets — Bikepacking, Minimal, High detail — each a full packer config shipped in
builder/presets/and directly usable with the CLI. An advanced editor still exposes every field the packer accepts (per-feature styling, LOD tiers, the bike-type routing profiles baked into the map, output settings), so nothing is lost for fine-grained work; exports are, again, plain CLI configs. - The binary is the schema authority. The same typed serde model owns the config's field names, types, optionality, and defaults;
schemarsderives its structural JSON Schema, then the packer adds the few semantic rules Rust types cannot express alone (serializer capacities, routing vocabularies, and UTF-8 byte limits).obc-pack schemaserves that generated contract, and the editor derives its capability from it. When the format grows — as v10's line styles (line_style,color2) did — the new fields appear because the model changed, rather than through a second hand-maintained frontend contract. A deterministic checked-in schema lets the local dev server start without a built binary, and a Rust test rejects that fallback if regeneration was forgotten; the desktop app has no fallback to reach for, because there the schema comes out of the packer it is linked against. - No state anywhere. The working config lives in the browser ("Custom — based on Bikepacking"), never on a server; builds stream their progress live. That shape survives having no server at all, which is what the two shipping tiers below actually do.
- Routes need no server at all. The GPX→OBCR converter is
no_stdRust, so it compiles to wasm and runs in the tab (obc-web-convert) — the same routine the device runs, producing the same bytes, held to the shared fixtures. Dropping a route on the builder uploads nothing; the same shim also turns a recorded.obctride log back into a GPX. Map building is the part that still needs real CPU.
One source, three hosts
Map building is memory-hungry, runs one job at a time, and costs a country's worth of CPU per user — so it cannot be hosted, and the builder is built three ways from one Svelte source. Which host is compiled in is a build-time alias, so the two you didn't build never enter the bundle.
| serves a map by | runs the packer | |
|---|---|---|
| Static site | handing over an artifact baked once, centrally | no — see below |
| Desktop app | building it on your machine | yes, in-process |
python -m builder.server | building it on your machine | yes, as a subprocess |
The Python server is the local development host; the two the world sees are the other two. What the desktop app adds is the two things a browser tab does not have — real CPU and a real filesystem — plus universal USB reach (WebUSB is Chromium-only). Custom styles, cropping to a box, a rides folder and the cable path for Safari/Firefox users all follow from those:
| Hosted site | Desktop app | |
|---|---|---|
| Prebuilt regional maps, in a few preset styles | yes | yes |
| Custom styles, the style editor | no | yes |
| Cropping to a box | no — whole regions only | yes |
| Drag-and-drop route conversion | yes | yes |
| Writing a map, route or firmware over USB | Chromium only | every OS |
| A map you just built, straight onto the device | — nothing is built here | one click |
| Ride export | one-shot GPX, no record kept | a managed library |
| Device dashboard, settings editor | no | yes |
The hosted site covers the whole "pick a region, pick a style, put a map and a route on the device" loop for someone who does not want to install anything. What it cannot do, it says at the moment you reach for it — a disabled control next to a sentence explaining why, never a hidden feature.
The packer is a library, not a subprocess. The dev server spawns obc-pack and reads its stdout to guess which stage it is in; the app links the crate and calls pipeline::pack — the same function the CLI's main calls, which is what makes "the app and the CLI produce the same bytes" a property rather than a hope. Two consequences are visible in the UI. The packer names its phases now instead of printing them for something else to match, so the progress bar reads a value; and a build can be cancelled, because the flag that stops it is read inside the ingest passes, the land clip, and the per-feature simplify, rather than between them.
Build, plug in, send
A build lands as a real file in a folder you can back up, and the device step then offers it directly — putting a fresh map on the computer is a click rather than a trip through a card reader. The bytes never enter the webview: the map is already on the same disk as the process that owns the USB endpoint, so the window hands over a path, and the several hundred megabytes go disk → endpoint inside Rust. The browser tier cannot do this — a page has no paths — so the same surface there streams a scratch file through the tab. One protocol, two ways of feeding it.
A build is never streamed straight into the cable with no file in between: the transfer descriptor announces the object's length and CRC-32 before the first byte moves, and neither is known until the packer has written its last chunk — so the .obcm has to exist first.
The rides come back the same way
The cable runs both directions, and the return trip is the one an Android or no-phone rider has no other answer for: without a companion app, a recorded ride has nowhere to go. The app keeps a managed folder — a real directory you can relocate, back up and open in Finder — with one small index.json and, per ride, two files: a GPX (what everything else reads) and the device's own ride object, byte for byte (the GPX export omits <time>, and the original bytes let a better exporter be re-run over an old ride).
What makes this more than a download folder is that the device is told. A ride the app holds is flagged synced, which lets the rider delete it on the device without a warning — and starts its auto-delete countdown. That flag is a durability claim, so the app earns it in order: write, fsync, rename, fsync the directory, commit the index, then ack. It is also why the browser tier keeps a one-shot export and no library: a download the rider can cancel at the save dialog is not a durable copy, so the hosted ride panel never acks.
The card, in the open
With a cable that lists as well as writes, the app can treat the device like the storage it is. Its Device page shows what the card holds: every route with the facts its list entry carries — including the retention clock — and every trip as the group of routes the device's own menu shows. Routes can be previewed (the stored polyline read back through the same obc-route reader the device draws from), renamed, deleted, and gathered into trips; dropping several GPX files at once offers to make the stages of a named trip in one motion. None of it needed a protocol feature: an upload to an existing id replaces the object atomically, so a rename is download-patch-replace under the same id, and a trip edit rewrites an object of a few dozen bytes. Rides are the deliberate exception — over the cable they are read-only, because deletion is the one act the sync semantics reserve for the device itself: a ride that only exists on the card must never be losable from a desk.
The app brings its own dependencies. Linking the packer means inheriting its one native dependency — libGEOS, the C++ library behind area assembly, the simplify, and the quadtree clip. A developer can brew install geos; someone who downloads an app must not have to. So the desktop build compiles GEOS from vendored sources, statically, into the binary, while the workspace build keeps using the system library — and that the two agree is asserted, not assumed: the same fixture packed through each produces the same digest on all three platforms. The same reasoning removed the last two shell-outs (curl and unzip for the land dataset — unzip is not a Windows program); both are Rust now, which also made the download cancellable and gave it a progress percentage.
The static tier, with no packer behind it
The site with no back end serves maps that were baked once and listed in a catalog manifest. The region picker is the same map; what changes is where a region's contents come from.
Three states, and the interesting one is the third. A region the bakery has built is shaded and clickable, and says what the download is: size, build date, and the date of the OSM extract it was packed from. A region nobody has baked is greyed with the reason and a way forward rather than a dead click. And a map whose obcm_version the connected device cannot read is shown as unsupported with the reason — not hidden, because hiding it would make a rider's out-of-date firmware look like a hole in the coverage, and not offered either, because the download would be a file the device refuses. That is OBCC §6(c) as a UI state; with nothing plugged in there is no such judgement to make, so the map is offered and its version stated.
Regions nest — a country and its states can be baked independently — and the manifest is explicit that neither substitutes for the other: they cover different areas at very different sizes. So the picker never quietly swaps one for the other. An unbaked region instead names the baked region that contains it and the baked regions inside it, each with its own size, and the rider chooses. What it cannot offer is a smaller piece of one: there is no packer here to crop with, so the download is the whole region, and the panel says so at the moment of selection rather than in a footnote.
The last step is the one that protects the card. A downloaded artifact is checked against the manifest's byte count and SHA-256 before it is written anywhere, so a truncated response or a bad publish is an error message in the browser rather than a map that fails on a mountain. That is why the whole file is buffered first: a digest can only be checked over complete bytes.
Where the hosted tier lives
There is no backend: the site is a directory of files, built from develop on every push as a single artifact with four surfaces — the landing page and its live device demo at the root (the firmware's own render path compiled to wasm), /docs/ and /blog/, and the builder at /builder/ with the region index baked in beside it (deploy-site.yml).
Two properties are worth keeping. Nothing in the artifact knows where it is mounted — every asset URL is relative, so the same bytes work under a project-Pages sub-path or at the root of a domain, and the deploy proves it each time by serving the artifact from a deep prefix. And a static host will not set your headers: GitHub Pages serves one fixed cache policy, which is why the catalog manifest is published with the map artifacts, on storage the bakery writes to, rather than beside the site — a manifest baked into the site could only change by redeploying it. (The same restriction means no COOP/COEP headers, so no SharedArrayBuffer and no threaded wasm; everything shipped today is single-threaded and unaffected.)
Following a route
You plan a route elsewhere and upload a GPX. Converting it to an .obcr — decimating the geometry for drawing while keeping the stats exact, then chunking it with shared seams — is covered on the data formats page. The converter is one portable no_std routine, so it runs on the device, in the simulator, or in a browser tab.
int16) — but accumulates distance and climb over every original point, so the route's stats are exact even though the stored line is sparse. Climb runs through a ±3 m dead-band: a move smaller than that books nothing and doesn't move the reference, so sampling jitter can't inflate the ascent. That one dead-band is shared by the converter, the elevation profile, and the device's live barometric climb — so the three numbers can't drift apart.What's left is the interesting part of following: snapping each GPS fix onto that route.
Map-matching: a forward-biased cursor
The matcher keeps a cursor — which segment of the route you're on — and for each fix searches a bounded window around it for the nearest segment. On-route that window is small and looks mostly forward, which is the whole trick: on a loop or an out-and-back, the cursor follows you forward instead of snapping to the nearby segment you rode an hour ago.
pub struct Match {
pub progress_m: u32, // distance travelled along the route — frozen while off-route
pub off_route: bool, // nearest segment is past the hysteresis threshold
pub dist_m: u32, // cross-track distance to the route — always live
}
Two rules keep it honest when you wander. Off-route freezes progress: a fix 200 m away can't drag your route position with it — but the search window widens so a rejoin further along is still found. And the off-route flag has hysteresis (off past 25 m, back under 15 m), so it doesn't flicker while you straddle the threshold. The live cross-track distance feeds the UI's off-route readout, and progress_m drives "distance to go." The projection it uses is shared with the GPX converter, so the matcher and the stored geometry measure the route the same way.
let (back, fwd) = if !self.started {
(i64::MAX, i64::MAX) // first fix: scan the whole route to lock on
} else if self.off_route {
(BACK_SEGS, FWD_SEGS_OFF) // off-route: widen the forward search to find a rejoin
} else {
(BACK_SEGS, FWD_SEGS_ON) // on-route: a tight forward window — O(window), forward-biased
};
Where this lives
- The packer pipeline, end to end and callable from either host:
obc-pack/src/pipeline.rs; the phase vocabulary and the cancel token it carries:obc-pack/src/progress.rs; the CLI around it:obc-pack/src/main.rs - Config + first-match styling:
obc-pack/src/config.rs - The config's generated JSON Schema fallback (served from the live model by
obc-pack schema):obc-pack/schema/config.schema.json - The builder's Svelte source and its local FastAPI dev host:
builder/server/; the desktop app that links the packer instead of spawning it:obc-desktop; the browser-side GPX↔OBCR conversion (wasm overobc-route):obc-web-convert - OSM ingest + relation assembly:
obc-pack/src/ingest.rs - The quadtree build:
obc-pack/src/quadtree.rs - Land generation:
obc-pack/src/land.rs - POI extraction, classification, name folding + dedup:
obc-pack/src/poi.rs - The navigation-graph build (routable filter, junction detection, edge split + dedup):
obc-pack/src/nav.rs - The on-device router (snap + profile-weighted A\* + OBCR emit):
obc-route/src/nav.rs - The route map-matcher:
obc-route/src/matcher.rs - GPX → OBCR conversion:
obc-route/src/convert.rs— one routine, three hosts (device, simulator, browser)
This is the offline bookend to the on-device story: the packer produces the map format the renderer draws, and the matcher drives the navigation the UI shows.