Skip to main content

roadgen

Generate 3D road networks — with a town beside them, or an open-pit mine round them, if you want one — and write the same network out as OpenDRIVE, as an Autoware-ready Lanelet2 map, as plain OpenStreetMap, as a SUMO network, as a CARLA UE5 asset package, as a ClipGT clip for NVIDIA Cosmos, and as a GPUDrive scene.

This is a generator, not a converter. You describe roads, lanes, junctions and the movements between them, and the library builds the geometry and writes the files. The one file it reads is its own kind: an OpenDRIVE document can be read back into the same model, whether roadgen wrote it or someone else did, and written out again in every other format.

Try it in a browser — the demo page runs this package, compiled for WebAssembly. Write a script, press Run, and flip between the formats it wrote. Nothing is uploaded and nothing is installed; see the demo page.

import roadgen

m = roadgen.Map()
a = m.add_road(
    start=(0.0, 0.0, 10.0),
    end=(100.0, 0.0, 12.0),
    lanes=[
        roadgen.Lane(width=3.5, direction="forward"),
        roadgen.Lane(width=3.5, direction="backward"),
    ],
)
b = m.add_road(
    start=(100.0, 0.0, 12.0),
    end=(200.0, 50.0, 15.0),
    lanes=[
        roadgen.Lane(width=3.5, direction="forward"),
        roadgen.Lane(width=3.5, direction="backward"),
    ],
)
m.connect(a, b)
m.generate_buildings()          # a town along every frontage, from a shape grammar
m.export_opendrive("map.xodr")
m.export_lanelet2("map.osm")
m.export_osm("openstreetmap.osm")
m.export_sumo("sumo/")
m.export_carla("Import/")       # .fbx + .xodr + the descriptor CARLA imports
m.export_clipgt("clip/")
m.export_gpudrive("scene.json")

How it is put together

                 Python API
                    PyO3
                     │
                     ▼
                 Rust core
                     │
       ┌─────────────┼─────────────┐
       │             │             │
   Topology       Geometry      Semantics
       │             │             │
       └─────────────┼─────────────┘
                     ▼
              Canonical Road IR ◀──── roadgen_carla::environment::Places
                     │                 ├─ buildings ◀── roadgen-buildings ◀── CGA shape grammar
                     │                 ├─ pits, heaps, pads: parts the land is shaped into
                     │                 ├─ corridors, hills, rivers: bridges, tunnels, earthworks
                     │                 └─ one land as a TIN round all of it (`spade`)
                 validate()
                     ▼
                ValidatedMap
                     │
                     ├──▶ OpenDRIVE      map.xodr            `opendrive`
                     ├──▶ Lanelet2       map.osm             `simple_lanelet2`
                     ├──▶ OpenStreetMap  plain .osm          `ll2-io`
                     ├──▶ SUMO           .nod/.edg/.con.xml  `quick-xml` + netconvert
                     ├──▶ CARLA          .fbx + .xodr        `roadgen-opendrive`
                     ├──▶ ClipGT         .parquet layers     `arrow`/`parquet`
                     └──▶ GPUDrive       scene .json         `serde_json`

              the written files ──▶ roadgen-viewer ──▶ SVG

                map.xodr ──▶ roadgen-opendrive::read ──▶ Canonical Road IR

Every exporter reads a ValidatedMap and writes nothing back into it: what a format cannot hold comes back to the caller through that exporter's check(), never as a field in the IR.

The arrow pointing into the IR is the environment a CARLA map is generated for, and it is a generator rather than an exporter: one value, Environment, whose branches are the kinds of place. Urban puts a town along the caller's streets with roadgen-buildings, and that crate is a generator — it reads roads and writes buildings, the same way the builder reads a RoadSpec and writes lanes. It runs after generation and before validation, because a lot is measured against geometry that does not exist until the first and buildings are part of what the second checks.

Mine fills the part of the IR a road network leaves empty — the shape of the land, what the land is, and the place they add up to — and lays out the roads that place needs: see Mines. Whichever branch runs, it writes only the IR, so what it generates reaches every format and not only CARLA's.

The two arrows at the bottom go the other way. roadgen-viewer reads the written files — never the IR — so a picture it draws is a picture of what a consumer would receive. It is what the demo page shows. And roadgen-opendrive::read is the one route from a file into the IR: OpenDRIVE is the format the IR was shaped against, so a document comes back as roads, lanes, movements, furniture and rules rather than as a picture — see Reading OpenDRIVE back. No other format is read, and docs/import-feasibility.md says why.

Four separations are load-bearing, and each is a module of roadgen-core:

  • Topology (topology) is connectivity and nothing else. A LaneConnection is two lane endpoints; it mentions no coordinate. Topology can be built before any geometry exists, and that is the order the builder works in.
  • Geometry (geometry) is three-dimensional from the start. There is no 2D point type. Where a planar computation is genuinely needed — an OpenDRIVE s coordinate, a lane offset — it goes through Frame3::to_local or a method whose name says horizontal, so the projection is visible at the call site. Lines, arcs, clothoids, Béziers and polylines are all Curve3, and a road's cross-section can be banked by a superelevation profile.
  • Semantics (semantics) is lane types, speed limits, markings, traffic lights, stop lines, crosswalks and right of way, as IR concepts. No OpenDRIVE <signal> and no Lanelet2 RegulatoryElement appears in the IR. A building is not one of these — it means nothing to traffic — so it lives in buildings with an arena and an identifier of its own.
  • Physical encoding lives in the exporter crates, which depend on the core and are never depended upon by it.

Design lineage

The separation of features from topology, of geometry from semantics, relationships as first-class objects, stable identifiers, and the split between a conceptual model and its physical encoding are ideas taken from GDF 5.1. Only the ideas: there is no GDF feature catalogue, no feature or attribute codes, no GDF XML. This is not a GDF-conformant implementation and does not claim to be.

What the types guarantee

Constraints that can be made unrepresentable are, rather than left to validation:

Type What it rules out
PositiveWidth a lane width of zero or less
SpeedLimit a non-positive speed
UnitVector3 a direction derived from a zero-length vector
Polyline3 a polyline with fewer than two distinct vertices
GeoOrigin a latitude or longitude off the globe
RoadId / LaneId / JunctionId / ConnectionId / ObjectId passing one kind of identifier where another belongs — it will not compile
ValidatedMap handing an unchecked map to an exporter

A map moves through MapBuilder → UnvalidatedMap → validate() → ValidatedMap, and the exporters take only the last of those.

Identifiers

Identifiers are derived from what the caller asked for, never from a counter, so regenerating a map and diffing the result is meaningful:

road/north
lane/north/0
lane/north/1
junction/j0
connection/j0/north_0/east_0

OpenDRIVE and Lanelet2 ids are assigned by the exporters, in the IR's own iteration order, and never flow back into the IR. What does come back is a record of them: every export writes a trace file saying which of its elements each IR element became — see following an element across formats.

Installing

Python

pip install roadgen

Wheels are published for Linux, x86_64 and aarch64, on the stable ABI, so one wheel serves Python 3.9 through 3.13; anywhere else pip builds from the sdist, which needs a Rust toolchain. From a checkout:

pip install maturin
maturin build --release          # or: maturin develop
pip install target/wheels/roadgen-*.whl

Rust

[dependencies]
roadgen-core = { git = "https://github.com/hakuturu583/hdmap_generator" }
roadgen-opendrive = { git = "https://github.com/hakuturu583/hdmap_generator" }
roadgen-lanelet2 = { git = "https://github.com/hakuturu583/hdmap_generator" }
roadgen-clipgt = { git = "https://github.com/hakuturu583/hdmap_generator" }
roadgen-osm = { git = "https://github.com/hakuturu583/hdmap_generator" }
roadgen-buildings = { git = "https://github.com/hakuturu583/hdmap_generator" }
use roadgen_core::prelude::*;

let mut builder = MapBuilder::default();
let lanes = || vec![
    LaneSpec::new(PositiveWidth::new(3.5)?, Direction::Forward),
    LaneSpec::new(PositiveWidth::new(3.5)?, Direction::Backward),
];
let a = builder.add_road(
    RoadSpec::line(Point3::new(0.0, 0.0, 10.0), Point3::new(100.0, 0.0, 12.0), lanes())?
        .with_name("a"),
)?;
let b = builder.add_road(
    RoadSpec::line(Point3::new(100.0, 0.0, 12.0), Point3::new(200.0, 50.0, 15.0), lanes())?
        .with_name("b"),
)?;
builder.connect(&a, &b)?;

let mut map = builder.finish()?;
roadgen_buildings::generate(&mut map, &roadgen_buildings::Rules::default())?;

let map = map.validate()?;
roadgen_opendrive::write(&map, "map.xodr")?;
roadgen_lanelet2::write(&map, "map.osm")?;
roadgen_osm::write(&map, "openstreetmap.osm")?;

let clip = roadgen_clipgt::ClipConfig::new("clip")
    .with_scenario_file("scenario.yaml")?;
roadgen_clipgt::write(&map, "clips/", &clip)?;

The Python API

roadgen.Map is a handle on a Rust MapBuilder. Road, Junction and LaneRef carry identifiers, not state: there is one model of the map and it is in Rust.

Call What it does
Map(name, origin, projection, handedness, sampling) a new network; origin is (lat, lon, alt)
add_road(lanes, start=, end=), add_road(lanes, points=) or add_road(lanes, alignment=) a straight road, one along a polyline, or one following an alignment
Alignment(start, heading) then .line(), .arc(), .spiral() a reference line built piece by piece
add_road(..., superelevation=[(station, radians), ...]) banks the cross-section
Lane(width_profile=[(station, metres), ...], taper=) a lane that narrows or widens
add_road(..., cross_sections=[(station, [lanes]), ...]) where the number of lanes changes
add_junction(name) a junction to route movements through
connect(a, b, junction=None, ends=("end", "start")) joins two roads by the named ends, pairing lanes
connect_lanes(from_lane, to_lane, junction=None) one specific movement
add_stop_line, add_traffic_light, add_traffic_sign, add_crosswalk road furniture; the first three take setback= metres back from the lane end
generate_buildings(enabled=True, rules=None, seed=0) a town beside the roads, on or off, by the rules you give it
building_ids(), building_kind(id), building_frontage(id), building_parts(id) what it generated
building_footprint(part), building_part_shape(part), building_part_kind(part), building_shell(part) one part's outline, its heights and roof, what it is, and the faces that bound it
add_traffic_light_rule, add_right_of_way, add_speed_limit rules over lanes
validate() / issues() / format_warnings() check before exporting
clipgt_warnings(scenario=None) what a ClipGT export would lose, and what its scenario gets wrong
gpudrive_warnings(scenario=None) what a GPUDrive export would lose, and what its scene runs up against
carla_warnings(name=, package=, buildings=, ...) what a CARLA package cannot carry, and what its import will get wrong quietly
osm_warnings() what a plain OpenStreetMap export would lose
sumo_warnings() what a SUMO export would lose
mgrs_grid() the grid square an MGRS map is reported in
export_opendrive(path) / export_lanelet2(path) / export_osm(path) write the files
export_sumo(directory) write a SUMO plain-XML network and its netconvert configuration; returns the prefix
sumo_lane_ids() where each lane of the map landed in the SUMO network
export_clipgt(directory, scenario=, clip_id=, frame_rate=, speed=, route=) write a ClipGT clip; returns the clip id
export_gpudrive(path, scenario=, name=, scenario_id=, steps=, time_step=, speed=, route=) write a GPUDrive scene
export_carla(directory, name=, package=, buildings=, use_carla_materials=, kerb_height=, verge_width=, ground_extent=, furniture=, carla_root=, engine=, sun_altitude=, sun_azimuth=) write a CARLA UE5 package and the script that imports it; returns what was written and what each mesh will be tagged
export_pointcloud_map(directory, name=, buildings=, kerb_height=, verge_width=, ground_extent=, spacing=, cell_size=) write Autoware's point-cloud map of the surface a CARLA package is built of: pointcloud_map/*.pcd and pointcloud_map_metadata.yaml, in the Lanelet2 map's frame
fetch_textures(package, overwrite=, resolution=) download the Poly Haven textures a written package asks for
carla_sky(carla_root, package, map_name, engine=, sun_altitude=, sun_azimuth=) after Import.py: give the imported level a daylight sky, with the editor's own scripting
carla_furniture(carla_root, package, map_name, engine=, manifest=) after Import.py: stand the package's traffic lights, signs and props town in the level, and make CARLA adopt the lights and signs
carla_water(carla_root, package, map_name, engine=, descriptor=, flowing=, still=) after Import.py: put the Unreal materials the descriptor names in the water's slots — M_Water_Flowing on rivers, whose UVs run downstream for a panning normal map, and M_Water_Still on lakes and ponds
to_opendrive_xml() / to_lanelet2_osm() / to_osm_xml() / to_gpudrive_json() the same, as strings
road_ids(), lane_ids(), connections(), successors(lane), lane_centerline(lane) inspect the built map
render_opendrive(path) / render_sumo(directory) / render_carla(path) / render_clipgt(directory) / render_gpudrive(path) read a written export back and draw it, as an SVG document
read_opendrive(path, sampling=, origin=, projection=) read an OpenDRIVE file as a Map — one that exports like any other and cannot be added to
read_warnings() what the file a map was read from said that the map could not keep
building_presets() / building_rules(name=None) the built-in rule sets, and one of them as text to edit

The render_* functions are module-level rather than methods on Map, because they read the file and not the map. Drawing the IR would agree with the IR by construction and so would say nothing about whether the export is right; drawing what was written says quite a lot. They are what the demo page puts on the screen, and in a notebook they are IPython.display.SVG(...).

Handedness decides which side of the reference line a forward lane lands on: "rht" (the default) puts it on the right, "lht" on the left. A lane can override it with side=.

The order of the lane list

The lanes= list is not read left to right across the road. Each lane goes to its side by its direction, and within a side the lanes are stacked outwards from the reference line in list order — the first forward lane is the one against the centre, the next is outside it, and so on; the same for the backward lanes on the other side. So a two-way street with pavements is written carriageway first:

lanes=[
    roadgen.Lane(width=3.5, direction="backward"),                    # against the centre
    roadgen.Lane(width=2.0, direction="backward", type_="sidewalk"),  # outside it
    roadgen.Lane(width=3.5, direction="forward"),                     # against the centre
    roadgen.Lane(width=2.0, direction="forward", type_="sidewalk"),   # outside it
]

Written left to right instead — pavement, carriageway, carriageway, pavement — the backward pavement takes the rank against the centre and the road has a footway down its middle. Nothing about a single road can tell that was not meant; the first connect() can, because lanes are paired across a joint by side and rank, and two such roads pair nothing. It raises rather than returning an empty list.

Alignments

A road's reference line can be a straight, a polyline, or a chain of lines, bends and transition curves. Alignment carries the state between pieces — where it has got to, which way it is pointing, how hard it is turning — so each piece begins exactly where the last one ended:

al = roadgen.Alignment(start=(0.0, 0.0, 4.0), heading=0.0)
al.line(80.0, rise=1.0)
al.spiral(60.0, curvature_end=1 / 120, rise=1.0)   # straight into the bend
al.arc(140.0, curvature=1 / 120, rise=2.0)
al.spiral(60.0, curvature_end=0.0, rise=1.0)       # and back out of it
al.line(80.0, rise=1.0)

m.add_road(lanes=[...], alignment=al, name="sweep")

The transitions are clothoids — curvature ramping linearly, which is what a vehicle traces while the steering wheel turns at a constant rate. Entering a bend straight from a straight is a step change in lateral acceleration; a spiral is what spreads it out. They reach OpenDRIVE as <spiral> elements, not as a chain of line segments.

A road can also be banked, by a roll profile against station:

m.add_road(
    lanes=[...],
    alignment=al,
    # Flat to 50 m, rolled to -0.06 rad by 100 m, held from there.
    superelevation=[(0.0, 0.0), (50.0, 0.0), (100.0, -0.06), (170.0, -0.06)],
)

Banking rotates the road local frame about its tangent, so a lane offset gains height and keeps its full width across the tilted surface. OpenDRIVE gets the same piecewise polynomial in <lateralProfile><superelevation>; Lanelet2 has no such concept and does not need one, because the roll is already in the heights of its vertices.

Corners

Two straight roads that meet at an angle — the map at the top of this file — are a kink, and a kink is something only one of the formats can hold. Lanelet2 is a list of points and can be cut on the slant; OpenDRIVE derives every lane from the reference line and a width measured perpendicular to it, so however it is written the two cross-sections end on different lines: a wedge of nothing on the outside of the turn, an overlap on the inside, in a file every consumer takes at face value. CARLA drives its traffic on that file.

So the corner is not written. When connect() joins two roads whose headings differ, each is cut back a little and a circular arc, tangent to both, takes the corner's place — half on each road, so no road is added and every link stays where it was. The arc's radius is 6 m plus however far the cross-section reaches on the inside of the turn, which keeps the inside kerb a curve a vehicle can follow whatever the road's width; on a road too short for that it shrinks to fit, down to a corner as tight as the road is wide, and past that the build fails and says so. A road that arrives at the joint on a curve is not bent either: give it an alignment that is tangent-continuous with the road it meets, or join the two through a junction.

The result is the same in every format, because it is in the IR: roadgen-viewer's OpenDRIVE panel draws a line, an arc and a line, Lanelet2 gets the arc's vertices, and the CARLA surface goes round the corner too.

A road given as a polyline — add_road(points=[...]) — bends at each of its vertices, and a bend inside one road is the same kink as a joint between two. So each interior vertex is rounded the same way, with the same radius rule, the chords either side cut back to meet the arc; a vertex the road has no room to round shrinks its arc to fit, and past a corner as tight as the road is wide the build fails and names the vertex. What comes out is a chain of lines and arcs, and every format gets that chain.

A cross-section that changes

Two different things, with two different answers:

A lane that narrows or widens stays one lane. Give it a width profile and the boundaries follow:

shoulder = roadgen.Lane(
    width=2.0, type_="shoulder",
    width_profile=[(0.0, 2.0), (60.0, 2.0), (100.0, 5.0), (160.0, 5.0), (200.0, 2.0)],
    taper="smooth",       # or "linear"
)

A WidthProfile cannot describe a width that reaches zero: every knot is a PositiveWidth, and both tapers are monotone between knots, so the value between two positive knots stays between them. That is why there is no free cubic here — an OpenDRIVE <width> is a cubic in general, and a general cubic can dip below zero between its ends. The smooth taper is exactly the cubic that cannot, and it lowers to <width> with no loss.

A lane that ends changes how many lanes the road has, so a new cross-section begins there:

m.add_road(
    lanes=[left, middle, outer],      # three lanes to begin with
    cross_sections=[(200.0, [left, middle])],   # two from 200 m on
    start=..., end=...,
)

Each cross-section becomes its own <laneSection> in OpenDRIVE and its own set of lanelets in Lanelet2 — which is what both formats need anyway, since a lanelet has exactly two boundaries and cannot gain a neighbour halfway along. The lanes that carry on across the boundary are connected automatically; the one that stops is not, so a routing graph will not drive off the end of it.

The same machinery lets a junction connector taper between lanes of different widths, instead of picking one and missing the other.

A lane off the road surface

A road's surface is its reference line, its elevation and its superelevation: one tilted plane across the road at every station. A pavement a kerb above the carriageway is not on it, so a lane can lift its two edges off the surface — inner at the edge nearer the reference line, outer at the edge further out, straight across the lane between them:

kerbed = roadgen.Lane(width=2.0, type_="sidewalk", height=(0.15, 0.15))
banked = roadgen.Lane(
    width=2.0, type_="sidewalk",
    height_profile=[(0.0, 0.15, 0.15), (50.0, 0.20, 0.25)],   # (station, inner, outer)
)

The lift is along the surface's normal — which the road's grade and superelevation tilt with it — runs straight from one knot to the next and holds before the first and after the last. That is OpenDRIVE's <height>, with its meaning, and it is written as <height> and read back from it. The lane's boundaries carry the lift, so every other format receives the raised lane without knowing about heights at all; Lanelet2, which makes two lanes side by side share their boundary linestring, gives an edge the two lanes lift differently — a kerb — a line each. A junction carries a lane's lift across it, so a raised pavement turning a corner stays raised and meets both arms without a step, and validation refuses a lift that changes where two lanes meet end to end.

Junctions

A junction is not a bare list of permitted turns: each movement gets a generated connector road, drawn as a cubic curve whose end tangents are pinned to the lanes it joins, so traffic leaves and arrives without a kink.

junction = m.add_junction("fork")
m.connect(trunk, straight_on, junction=junction)
m.connect(trunk, slip, junction=junction)

That one model lowers cleanly both ways: the connectors become OpenDRIVE connecting roads inside a <junction>, and lanelets that a Lanelet2 routing graph walks through. The connecting road's reference line is the cubic itself, a <paramPoly3>, not a chain of chords through its vertices: a chain would put a heading step at every vertex and end some degrees off the tangent it shares with the arm, and OpenDRIVE lays lane edges perpendicular to the heading, so the connector's cross-section at the mouth would be turned against the arm's. The vertices Lanelet2 gets are on the cubic, at its own arc length, so the two files put every lane edge in the same place.

connect joins the end of the first road to the start of the second, which is the shape of a road carrying on into the next one. A crossroads is the other shape: its approaches all point at the centre, so they meet the joint end to end, and ends says so.

junction = m.add_junction("x")
for a, b in itertools.combinations(arms, 2):
    m.connect(a, b, junction=junction, ends=("end", "end"))

One call per pair of arms, not per movement: joining two two-way roads already generates the connectors for both directions.

Get this wrong and nothing complains — the map validates and both files are written — but the connectors run to the far tips of the approaches rather than across the junction, so it is worth reading the connector lengths once.

Pavements go round the corner, not across

Only a lane that carries a movement — driving or biking — is connected through a junction; a shoulder, a parking lane or a border stops at the arm, because nothing moves along it to be carried across. A sidewalk lane is never connected through either: a pedestrian does not walk across a crossroads the way a car drives across it. Instead, at every junction, each pair of arms that are neighbours round the junction gets a connector between their outer sidewalks — a pavement round the corner, a single sidewalk lane drawn as the same tangent-pinned curve a traffic connector is. A crossroads with pavements on all four arms comes out with eight traffic connectors and four corner pavements, and the only ways across the carriageway are the crosswalks the caller put down. The corner pavements are ordinary roads in the junction — connecting roads in OpenDRIVE, lanelets in Lanelet2, a surface in CARLA — so a pedestrian network is walkable in every format without any of them having to know it was made differently.

Traffic control

Lights, signs, stop lines and crosswalks are IR objects — a position and the lanes they govern — and both formats get them:

IR OpenDRIVE Lanelet2
Traffic light <signal dynamic="true"> with <validity>, its housing's height, a <userData> per lamp traffic_light way with its height, light_bulbs way + traffic_light regulatory element
Traffic sign <signal> carrying the caller's catalogue code traffic_sign way, code as its subtype
Stop line <object type="roadMark" name="stopLine"> stop_line way, the rule's ref_line — a traffic-light rule with several (one per junction mouth) is one regulatory element per line, on the rule's lanelets it names, referring to the lights over them
Crosswalk <object type="crosswalk"> with its outline as <cornerLocal> corners a lanelet of subtype crosswalk
Right of way <junction><priority high low> right_of_way regulatory element

A light, a sign or a stop line goes at one end of a lane, or setback metres back along the lane from it — m.add_stop_line(lane, setback=8.0) — which is where a stop line belongs when a crosswalk lies between it and the junction, and where a stop sign stands beside it.

OpenDRIVE places an object at (s, t, zOffset) in one road's own coordinates, so the exporter projects the IR's position through the road local frame — a nearest-point search for the station, then Frame3::to_local. Height is measured away from the road surface rather than straight up, which is what "five metres above the road" means on a slope and what zOffset carries.

A traffic light's line is the bottom edge of its housing, which is where zOffset puts a signal. add_traffic_light gives a light a standard head: a housing 0.38 m wide and 1.05 m tall over the middle of its lane, its bar that wide at the height asked for, and three lamps one above the other — green, yellow, red from the bottom. A light can also say what Autoware draws of it besides — the height of its housing and its lamps, each with a position, a colour and an arrow — as its LightHead, and both formats carry it: Lanelet2 as Autoware writes it, the height tag on the light's way and a light_bulbs way of tagged points naming the light by traffic_light_id; OpenDRIVE as the signal's height and one <userData code="lightBulb" value="color=green;arrow=right;x=…;y=…;z=…"> per lamp, in the document's inertial frame, which the OpenDRIVE reader reads back and any other consumer passes over.

A crosswalk's outline is written as <cornerLocal> corners in the object's own frame — the object lying across the road, its hdg a quarter turn from the road's, the first corner repeated to close the ring — rather than as <cornerRoad> corners in road coordinates, which the standard also allows. CARLA reads only the first kind into its pedestrian navigation; a crossing it cannot read is one nobody crosses at.

A signal's type is a code from a country's catalogue rather than a name of its own, so a traffic sign passes the caller's code straight through (the CARLA package is the one exception, and says which codes it recognises), and a traffic light is written as the German catalogue's three-colour light (1000001) — the value OpenDRIVE tooling expects in a generated map, and one a caller with another catalogue can rewrite after export. Every light is also in a <controller>: the lights of one traffic-light rule are one controller, a light no rule names shares one with the other unnamed lights on its approach, and each junction names the controllers that lead into it. That is OpenDRIVE's only way of saying which lights switch together, and what a consumer running the junction looks for.

Buildings

Off by default. Switched on, every road grows two frontages of lots and a CGA shape grammar — the CityEngine formalism, derived by symbios-shape — decides what stands on each one.

m.generate_buildings()                        # on, a mixed town street
m.generate_buildings(rules="downtown")        # on, one of the built-in sets
m.generate_buildings(rules=text, seed=7)      # on, rules of your own
m.generate_buildings(False)                   # off

That is the whole of the Python surface: whether to, and by what rules. Everything else — how wide a lot is, how far back from the kerb, how many storeys — is in the rules, because the alternative is a dozen keyword arguments that mean nothing without the grammar they go with.

The division of labour

Two questions have to be answered to put a building somewhere, and they are different questions.

Where may a building stand? That one is about the road network, so roadgen answers it. The road surface is taken from the generated cross-section — the real one, lane by lane, sampled along the reference line, so a road that tapers or gains a lane is measured where it actually is — widened by the setback, and cut into lots along each frontage. Nothing is placed in the middle of a block: the middle of a block is gardens, yards and the backs of the buildings on the next street, and inventing masses there would be inventing a land-use map rather than deriving one.

What stands there? That one is architecture, so the grammar answers it. Each lot arrives as a rectangle on the ground — local X along the frontage, Y up, Z away from the road, so the face Comp(Faces) calls Front is the one looking at the street — and what comes back is a set of oriented boxes.

Then roadgen has the last word: a lot whose buildings would reach into a road or onto a neighbour is dropped whole, so a gap in a street is a gap and never half a house.

The rules

One piece of text. Derivation starts at the rule called Lot, and the word a mass is emitted with becomes the building's kind:

attr Setback = 5             // kerb to lot, metres
attr LotWidth = 15           // frontage per lot
attr LotDepth = 18           // how far back a lot reaches
attr LotGap = 4              // left clear between neighbours
attr CornerClearance = 12    // left clear at each end of a frontage
attr FloorHeight = 3.2       // what a storey is, for the storey count

Lot     --> Size(scope.x - 2, 0, scope.z - 2) Center(XZ) Plot
Plot    --> 45% House | 30% Terrace | 15% Shop | else: Block

// A pitched roof is the top of the mass, split off and given a shape.
House   --> Extrude(rand(6, 9)) Split(Y) { ~1: Walls | 2.5: Gable }
Walls   --> I("house")
Gable   --> Roof(Gable, height=scope.y) { Slope: Tiles | GableEnd: Wall }
Terrace --> Split(X) { ~1: Unit | ~1: Unit }
Unit    --> Extrude(rand(6.5, 8.5)) Split(Y) { ~1: Party | 2: Gable }
Party   --> I("terrace")
Shop    --> Extrude(FloorHeight * 2) I("retail")
Block   --> Extrude(FloorHeight * rand(3, 5)) I("apartments")

// is CGA's line comment, and # is accepted beside it because rules are usually written inside a Python string and # is what the hand reaches for there.

Those six attr declarations are the numbers roadgen needs, read back out of the grammar rather than passed beside it — so there is one thing to edit, and a rule can read LotDepth and set a footprint against it. Undeclared ones keep their defaults, and every one of them is readable from the grammar either way: a knob that only worked once you had declared it would be a knob with a trap in it.

Everything else is ordinary CGA: Extrude, Split, Repeat, Comp(Faces), ShapeL, Roof, weighted and guarded variants, rand() and scope.x in any numeric position. symbios-shape's README is the reference.

One idiom is worth knowing, because a lot arrives flat: Size(scope.x - 2, 0, scope.z - 2) Center(XZ) is how you leave a margin round the edge of a plot. Offset(-2) is CGA's inset, and it wants a scope with height — on a footprint, whose Y extent is zero until Extrude runs, it refuses.

building_presets() lists the four built-in sets — town, suburban, downtown, industrial — and building_rules(name) hands one back as text, so rules of your own start as a preset with a line changed rather than as a blank page.

The model

A building is not a shape. It is a complex feature: a set of parts, what they are for, and what they stand beside. That is the same relationship a road has to its lanes, and it is kept the same way — the building lists its parts, each part names its building, and both live in arenas keyed by identifier.

Building        parts, kind, frontage
  └── Part      solid, kind, levels
        └── Solid    footprint, wall_height, roof
              └── Roof   shape, height, direction

Each part is a solid, not an outline with a number bolted on:

  • its footprint is a ring whose vertices carry their own heights, so a part that starts partway up a building says so with nothing extra, and one on sloping ground is on sloping ground;
  • its walls rise from that ring;
  • its roof is flat, skillion, gabled, hipped or pyramidal, with a rise and — where the shape has a ridge — the direction it runs in.

A part carries a kind of its own where it has one: a tower of offices standing on a shopping podium is one building, and neither word describes both halves of it.

Solid::shell() hands back the faces that bound it: the base, one quad per wall, and the roof. Every edge of that surface is used by exactly two faces, which is what makes it a solid rather than a pile of panels, and Solid::is_closed() is that property written down where it can be checked. The compact form is what the IR stores because it is what every format asks for; the shell is what it means. In Python, building_shell(part) returns it.

Frontage is the third piece, and it is a relationship rather than a coordinate: which road the building faces, which side of it, and how far along. It says nothing about where the building is — the geometry does that, completely — and everything about what it belongs to. The OpenDRIVE export reads it instead of searching for the nearest road, and a caller who wants the buildings of one street has a way to ask.

What counts as one building

A derivation is a flat list of boxes and panels, and most grammars produce more of them than there are buildings. Three rules turn one into the other, and each is about what a box is rather than what it was called:

  • a box with extent on all three axes is a mass: one on the lot's ground starts a building, one standing on another mass is a further part of that building. So a stack of floors, a tower on a podium and a wing behind a house come out as one building of several parts — and two masses side by side on the ground come out as two buildings, which is what a terrace is;
  • a box with no thickness is a panel. Panels sitting on a part's eaves are its roof;
  • anything standing over nothing is dropped, because there is no part to give it.

A facade's windows fall out of this on their own: Comp(Faces) produces scopes with no thickness, and a window stands against a wall rather than on the eaves, so it is neither a part nor a roof.

Reading a roof

The grammar knows fifteen roof types; the IR has words for five, because those are the five a consumer can be told about — they are OSM's roof:shape values. What is read back is geometry, not the grammar's vocabulary: the ridge is where the roof is highest, and the shape follows from where that ridge lies over the part it covers.

The highest points of the roof The shape
one point pyramidal
a line down the middle, spanning the part gabled
a line down the middle, stopping short of the ends hipped
a line along one side skillion

So Roof(Pyramid, …) over an oblong reads back as hipped, because what the engine draws there has a ridge and what has a ridge is a hip. That is the point of reading geometry: the word follows the shape rather than the other way round.

A roof that is none of the five — an M-shape has two ridges, a mansard a flat top — is not called the nearest one. It becomes the flat top of the volume that contains it: the massing stays right, and only the word for the shape is missing.

A town is a pure function

The rules, the map and the seed decide the town, every time. Each lot is seeded from its own identity rather than from a running counter, so adding a street changes that street's buildings and leaves every other one exactly as it was — the same property the identifiers have, for the same reason:

building/high_street/left/3/0
part/high_street/left/3/0/0        ← the part that meets the ground
part/high_street/left/3/0/1        ← the one standing on it

Where they end up

Format What it does with a building
OpenStreetMap Simple 3D Buildings: the outline as a closed building=<kind> way, a building:part=yes way per part where there is more than one, with height, min_height, building:levels, roof:shape, roof:height and roof:direction. The solid survives; the frontage, a sloping base and the union of several parts' outlines do not, and check says so.
OpenDRIVE one <object type="building"> carrying an <outline> per part, corners as <cornerLocal>. The massing survives; the roof shape does not, and format_warnings() says how many were flattened.
CARLA actual walls and roofs, from Solid::shell(). The whole solid survives — it is the only export that draws the roof rather than describing it — but CARLA's import will tag it Terrain unless the town is exported as props, in which case it is tagged and the package's script places it.
Lanelet2, SUMO, ClipGT, GPUDrive nothing; each one's warnings say how many were dropped.

OSM measures a roof's direction clockwise from north, as a bearing; the IR measures it anticlockwise from east, as an angle. That conversion is the whole of the difference between them, and it happens in the exporter where it belongs.

The OpenDRIVE outline is cornerLocal rather than cornerRoad on purpose. Both would place the corners; only the first keeps them rigid. A cornerRoad corner is its own (s, t) pair, so beside a bend a straight wall is written as a curved one and a consumer evaluating the file back gets a banana. cornerLocal measures every corner in one frame — the road's, at the building's own station — so the shape that comes back is the shape that went in. The viewer draws every part from those outlines, which is why a town shows up in the OpenDRIVE picture and in no other.

Mines

Not every map is a town. A mine is put together from parts, the same way a town is put together from roads: a pit, the roads into it — any shape, drawn with add_road like every other road — the heaps they lead to, a yard for the workshops, a berm along the ramp. Once the roads are built, the land round all of it is generated as one triangulated surface.

m = roadgen.Map(name="quarry")
pit = m.add_pit(name="North Pit", centre=(0.0, 0.0, 0.0), benches=6, bench_height=12.0)

haul = [
    roadgen.Lane(width=7.5, direction="forward"),
    roadgen.Lane(width=3.0, direction="forward", type_="shoulder"),
    roadgen.Lane(width=7.5, direction="backward"),
    roadgen.Lane(width=3.0, direction="backward", type_="shoulder"),
]
# The ramp is a road like any other. pit.ramp() gives the points of the usual
# spiral down the wall; pass points of your own for any other shape.
ramp = m.add_road(points=pit.ramp(grade=0.1, clockwise=True), lanes=haul,
                  pavement=[(0.0, "gravel")], site="North Pit")
m.add_barrier(ramp.lane(1), kind="berm", height=1.5)     # on the pit-side shoulder
m.add_road(start=(100.0, -260.0, 0.0), end=(300.0, -260.0, 0.0), lanes=haul,
           pavement=[(0.0, "gravel")], site="North Pit")
m.add_heap("waste_dump", centre=(440.0, -260.0, 0.0), radius=110.0, height=30.0,
           site="North Pit")
m.add_pad([(140.0, -320.0, 0.0), (260.0, -320.0, 0.0), (260.0, -272.5, 0.0),
           (140.0, -272.5, 0.0)], buildings="industrial", site="North Pit")

m.export_carla("Import/", name="Quarry", use_carla_materials=False)

site = m.site("site/north_pit")          # {"kind": "open_pit_mine", "land_covers": [...], ...}
nodes, triangles = m.tin_mesh(site["surfaces"][0])
m.terrain_height(0.0, 0.0)               # the pit's floor: -72.0

A second pit is a second add_pit, with its own roads and its own site: every part shapes the same land, which is stated once for the whole map, and two parts that overlap are refused.

What it adds to the IR is the land, said once and in the IR's own terms — the GDF ideas the rest of it is built on, applied to the Terrain Elevation and Land Cover themes:

IR What it is
TinSurface the land as a feature: a TIN whose nodes and triangles are its geometry, not features of their own
TinBreakline a line the land breaks along — a bench's crest or toe, a heap's, a road's edge — and an edge of its surface, which validate() checks
LandCover what an area of land is (pit, waste_dump, stockpile, pad), draped_on the surface it lies on
Site the complex feature that gathers them, and the roads, into one mine
Road.pavement paved or unpaved, then what with — so an unpaved asphalt road is a value the type cannot hold
MapObjectKind::Barrier the safety berm along the ramp's pit side

A mine and a town are not two generators: they are parts on one map. The land is one TIN builder — every road's edges as breaklines, the country rolling away from them — shaped by whatever pits and heaps are on the map, or by nothing (m.generate_land() states a town's land the same way, and tunes a mine's). The buildings are one shape grammar on lots along roads: a town's streets (generate_buildings(), kept off the pits, heaps, pads and site roads), or a pad's.

The roads go into the TIN as breaklines, so the land under a road is the road's own plane, vertex for vertex. CARLA builds its ground from the TIN rather than from the roads — rock in the pit and on the heaps, gravel on the haul roads and the yard; OpenDRIVE writes the gravel as <lane><material> and the berm as a barrier object; OpenStreetMap writes landuse=quarry, man_made=spoil_heap and surface=. The rest write the roads and say what they dropped. The design, and what is not done yet, is in docs/mine-design.md.

Motorways

A motorway is put together the way a mine is: from parts and roads. What is new is that a road can leave the ground. A bridge or a tunnel is a feature of its own, carrying stretches of road; a road says along its length which stretches are on one; and wherever one road passes over another, a grade separation says so and names the structure that does it.

m = roadgen.Map(name="crossing")
m.add_road(start=(-300.0, 0.0, 9.0), end=(300.0, 0.0, 9.0), lanes=two_way,
           name="motorway", type_="motorway", form="motorway")
m.add_road(start=(0.0, -200.0, 0.0), end=(0.0, 200.0, 0.0), lanes=two_way, name="street")
m.add_bridge([("motorway", 200.0, 400.0)], form="viaduct", name="Street Viaduct")

m.structure("structure/street_viaduct")   # {"form": "viaduct", "supports": [...], ...}
m.grade_separations()                     # [{"over": ("road/motorway", ...), "under": ("road/street", ...), ...}]

form= is the road's form of way — motorway, slip_road, multiple_carriageway, parallel_road, … — kept apart from type_, which says what kind of place the road is in. A motorway or a slip road crosses nothing on the level: one that crosses another road away from a junction, with nothing carrying one over the other, fails validation. Two ordinary streets crossing without a junction are still a map.

A corridor plans the roads

Drawing two carriageways, the slip roads, the speed-change lanes and the junctions by hand is a lot of points. A corridor is a part that knows the rules a motorway is built to — Japan's Road Structure Ordinance for the design speed asked for, as defaults — and draws a first plan of every road it needs:

e1 = m.add_corridor("E1", [(-2000.0, 0.0, 0.0), (1000.0, 0.0, 0.0), (3000.0, 700.0, 0.0)],
                    design_speed=100.0, profile=[(0.0, 0.0), (500.0, 10.0), (4900.0, 0.0)],
                    end_transition=400.0)
e1.add_interchange("Kita IC", at=1700.0, crossroad=[(-300.0, -450.0, 0.0), (-300.0, 450.0, 0.0)],
                   crossroad_lanes=street)
m.add_river(centre=[(-1450.0, -600.0, 0.0), (-1350.0, 600.0, 0.0)], width=80.0, depth=6.0)
m.add_hill(crest=[(1650.0, 1350.0, 0.0), (2350.0, -650.0, 0.0)], width=700.0, height=50.0)

plan = e1.plan()          # roads, junctions, movements, signals, structures — and notes
plan.roads[0]["points"]   # every road's points, to look at or replace with set_points
m.add_plan(plan)

The plan rounds the centre line's corners at the design speed's radius, rounds the profile's crests and sags with vertical curves, splits each carriageway where a slip road leaves or joins it with a deceleration or acceleration lane before the nose, lifts the crossroad over the motorway on an overpass, and signals the ramp terminals. Nothing in it is fixed: it is points and lanes, and the caller can move any of them before adding it. A road drawn outside the standard is noted, not refused.

kind= says what the interchange is built as: a diamond (the default) or a half_diamond, whose ramp terminals are signalled; a parclo, whose turns on across the crossroad are loops; or one of the free-flowing kinds, where every slip road merges or diverges and nothing is signalled — a trumpet, whose crossroad crosses on its overpass and splits round a loop on the far side; a cloverleaf, a loop in each quadrant, its slip roads off and on to a collector–distributor road beside each carriageway so the weaving stays off the through lanes; and a directional T, where another motorway, drawn by crossroad, comes from side and ends at this one, its flyovers to and from the far carriageway stacked three levels high on viaducts.

When the map is built, the rest follows from the land. The natural land — the country, the hills, the rivers, the parts — is worked out before the motorway is fitted to it, and every corridor road is classified along its length: far above the land or over water, a bridge (a viaduct when it is long); far below it, a bored tunnel; otherwise the ground, cut and filled to meet it, with the cutting's crest and the embankment's toe as breaklines. Anywhere one road crosses over another with nothing under it, an overpass is put there. Piers are spaced along every bridge and kept off every carriageway below. elevated= and sunken= stretches lift a corridor onto a viaduct or drop it into a cut-and-cover tunnel — through a town, say — and add_ramp() adds a lone slip road for a street to be joined to. With urban="elevated" (or "sunken") the corridor finds the town itself: wherever its line comes within 60 m of a street already on the map, or of a pad with buildings, it goes onto a viaduct (or into a tunnel), with room either side to climb to it at its grade.

The town keeps to its side: no building faces a motorway or a slip road, none stands under a deck or on a corridor's slopes.

IR What it is
Structure a bridge (bridge, viaduct, overpass) with its abutments and piers, or a tunnel (bored, cut_and_cover, underpass) with its portals
Road.structure which stretches of the road are on, or in, which structure
GradeSeparation "this road passes over that one (or over water) by way of this structure"
Road.form the road's form of way
Interchange the junctions, slip roads and structures of one interchange, as one feature

Every format gets what it can hold. OpenDRIVE writes <bridge> and <tunnel> and reads them back; OpenStreetMap splits a way at each structure and tags bridge=, tunnel=, layer= and motorway_link; Lanelet2 marks motorway lanelets highway; GPUDrive, which is flat, cuts the road edges where one road passes over another; CARLA draws the decks' undersides and parapets, the piers and abutments, the tunnel bores and portals, and lays the land under the bridges and over the tunnels. The design — and what is not done yet — is in docs/highway-design.md.

Coordinates

A map's own x and y are always metres about its origin — that is what makes a generated map easy to write. projection says what frame those metres are read against, and therefore what a consumer reconstructs:

projection Lanelet2 local_x/local_y
"local_cartesian" (default) the map's own metres
"utm" the same, with latitudes and longitudes taken through UTM
"mgrs" metres within the 100 km MGRS square the origin falls in

MGRS is the coordinate system an Autoware map built with the MGRS projector uses. Each node's grid position is worked out from that node's own latitude and longitude, not by shifting the whole map by the origin's grid position: a metre of local east is not a metre of UTM easting, and over a few kilometres the difference shows. Map.mgrs_grid() gives the square's reference for map_projector_info.

Heights are the map's own under every projection: a Lanelet2 node's ele is its z, the same number OpenDRIVE, SUMO, ClipGT, CARLA and the plain OSM export carry — and the number Autoware's MGRS, UTM and local projectors use as z when they load the map. It is not the ellipsoidal height of the point (which a local Cartesian frame puts d²/2R above the tangent plane, already 3 m at 6 km from the origin); a consumer that wants that has the latitude, the longitude and the origin's altitude to compute it from.

A map with MGRS coordinates has to fit inside one square. ll2's MGRS projector takes the easting and northing modulo 100 km, so a map running over the edge would silently come back on the other side; format_warnings() reports it instead, and exporting fails rather than writing it.

The OpenDRIVE file's coordinates are the map's own metres under every projection, and its <geoReference> describes those metres: a transverse Mercator about the origin for local_cartesian and mgrs, and for utm the zone's transverse Mercator with the origin's easting and northing folded into the false origin, so that a PROJ consumer reading the file lands on the latitudes the Lanelet2 export wrote. The CARLA package's .xodr is the one exception: CARLA's GNSS sensor reads only +lat_0/+lon_0 from the string, as where (0, 0) stands, so that file names the origin there whatever the projection.

OpenStreetMap

export_lanelet2 and export_osm both write .osm, and they are not interchangeable. Lanelet2 uses the OSM container for something else: its ways are lane boundaries, its relations are lanelets, and nothing in it carries a highway tag, so a router or a renderer sees no roads at all. export_osm writes OSM as OSM means it.

m.export_osm("map.osm")

OSM describes a road as one way down the centreline with the lanes as a count:

<way id="-4">
  <nd ref="-2" /><nd ref="-3" /><nd ref="-1" />
  <tag k="highway" v="residential" />
  <tag k="lanes" v="2" />
  <tag k="lanes:forward" v="1" />
  <tag k="lanes:backward" v="1" />
  <tag k="name" v="north" />
  <tag k="oneway" v="no" />
</way>

highway comes from the road type, maxspeed from the speed limit in km/h, and sidewalk from where the footways are in the cross-section. oneway is yes, no or -1 — the last for a road whose every lane runs against the reference line, which is why the way's node order follows that line rather than being reversed for convenience.

Buildings go out as Simple 3D Buildings: the outline as a closed building=<kind> way, a building:part=yes way per part where there is more than one, and height, min_height, building:levels, roof:shape, roof:height and roof:direction saying what each occupies. This is the one part of the export that loses nothing, because OSM has a model for a building of several parts and the IR has the same one. Terraced buildings share their corner nodes, which is welding doing for a terrace what it does for two roads meeting at a junction.

Furniture goes on the way's own nodes, which is where OSM puts it: highway=traffic_signals, highway=stop, highway=crossing and traffic_sign=<the caller's code>. A node is inserted at the object's own position rather than snapped to the nearest vertex — a straight road has two of those, and everything on it would otherwise pile up on one end. Where several controls share a point, the stronger keeps the node's single highway tag: a signalised stop is signals, not a stop sign — and osm_warnings() says which pair was folded together. A crossing is a place rather than a control, so it always has a node of its own, however close the stop line before it stands, and it also gets a highway=footway + footway=crossing way across the road, through that node, so the footway and the road share a vertex.

Junctions, which is where the two models disagree

OSM has no connector roads. Arms meet at one shared node, and every turn is legal unless a type=restriction relation says otherwise. The IR is the other way round: it enumerates the movements that are permitted and draws a connector for each.

So a junction becomes a single node where the arms' centrelines would actually cross, every arm's way is extended to it, and a pair of arms with no movement between them becomes a no_left_turn / no_right_turn / no_straight_on relation. The connectors are not written: twelve ways across a crossroads would be twelve roads that do not exist.

Extending the arms is the one place this export moves geometry, and it is what makes the result routable — four ways stopping 14 m short of each other are four dead ends. The node is the least-squares intersection of the arm centrelines rather than the average of their ends, so two arms meeting at a corner land where the roads cross and not out in the middle of the bend.

U-turns are deliberately never written. The IR enumerates movements between different arms, so it never says anything about turning back the way you came, and a no_u_turn on every arm would be inventing a rule the map does not hold.

What it cannot carry

  • Height. An OSM node is a latitude and a longitude; elevation is the ele tag convention rather than part of the geometry. The heights survive as those tags and nothing else.
  • Lane geometry. A lane is a number. Widths, boundaries, markings, tapers and cross-section changes have nowhere to go — a road whose lane count changes is written with its first section's, and osm_warnings() names it.
  • Superelevation, and any per-lane rule.

Identifiers are negative and the file carries no version, which is how OSM data says it was never uploaded. That is what a generated map is.

SUMO

SUMO's simulator reads a .net.xml, and a .net.xml is a build product: it carries the shape of every junction, the internal lane through every movement, and the right-of-way matrix that decides who waits for whom — all computed by netconvert. Writing one directly would mean reimplementing netconvert, and getting it subtly wrong.

So what export_sumo writes is netconvert's own input, the plain XML network, which is the format a generator is meant to produce:

prefix = m.export_sumo("network/")     # returns the name the files were given
file what is in it
<name>.nod.xml junctions and road ends, as points
<name>.edg.xml one edge per direction of travel, with its lanes
<name>.con.xml which lane may be left for which lane
<name>.tll.xml the program of each signalised junction, when the map has one
<name>.netccfg the netconvert run that turns the rest into a .net.xml
<name>.safe.{src,dst,via}.xml randomTrips.py weights; see Random traffic
netconvert -c network/demo_town.netccfg

One road, two edges

A SUMO edge is a one-way bundle of lanes, so a road carrying traffic both ways is two of them pointing at each other:

<edge id="north.fwd" from="n_north_start" to="j_x" priority="5" numLanes="1"
      speed="13.890" spreadType="center" name="north"
      shape="-1.750,70.000,0.000 -1.750,14.000,0.000">
  <lane index="0" width="3.500" disallow="pedestrian"
        shape="-1.750,70.000,0.000 -1.750,14.000,0.000"/>
</edge>

Every lane is written with its own shape, so what SUMO gets is the geometry the generator computed — not a centreline with a width laid out from it, which is what a network imported from OpenStreetMap has to make do with. The heights go with it: a SUMO shape is x,y,z, so the relief that plain OSM could only put in ele tags is part of the geometry here.

Lanes are numbered from the outside of the carriageway, index 0 being the kerb lane: the rightmost in the direction of travel where traffic drives on the right, the leftmost where it drives on the left. The IR counts outwards from the reference line instead, which for the opposing carriageway is the other way round — so the same physical lane has different numbers in the two directions. sumo_lane_ids() is the way back:

dict(m.sumo_lane_ids())["lane/north/1"]     # 'north.bwd_0'

Left-hand traffic

netconvert does not read handedness off the geometry: it builds a right-hand network unless it is told otherwise. A map made with handedness="lht" therefore gets lefthand in its .netccfg, and netconvert records it on the <net> it writes:

<processing>
    <offset.disable-normalization value="true"/>
    <no-turnarounds value="true"/>
    <lefthand value="true"/>
</processing>

That is more than bookkeeping. Left-hand traffic decides which turn crosses the oncoming carriageway — the right one — and so which movement on a major approach has to give way, which side of a junction the internal lanes are laid out on, and which lane is the slow one. Without it a left-hand map would be simulated as a right-hand one drawn on the wrong side of the road: right turns would cut across oncoming traffic with priority, and the kerb lane would be SUMO's overtaking lane. A right-hand map writes nothing, since that is netconvert's default.

speed is the road's limit in m/s, or SUMO's own default for the OSM highway value the road type maps to. A lane with a limit of its own carries it as the lane's speed, and so does a lane a SpeedLimit rule names — the rule is that lane's limit and replaces any it had, as it overwrites the lanelet's speed_limit tag in the Lanelet2 export:

<lane index="0" width="3.500" shape="..." speed="8.333" disallow="pedestrian"/>

An IR lane runs the whole of one cross-section, which is exactly one lane of one edge, so the rule covers the SUMO lane from end to end. Where two rules name one lane, the lower is written. The trace records the rule as merged into each lane: it set, with role speed, the way a right-of-way rule is merged into an edge's priority.

What may use a lane is the whole of what SUMO knows about lane type: a driving lane is written as one pedestrians are kept out of, a footway as one that admits only them, a bike lane only bicycles and a hard shoulder only emergency vehicles.

Lane changes

SUMO has no paint, but it has what the paint between two lanes is for: whether a vehicle may change across it. Each lane is written with changeLeft and changeRight read off the IR's marking on the boundary it shares with its neighbour in the edge — left_marking and right_marking on a LaneSpec, or the left_marking= / right_marking= arguments of Lane in Python:

marking between the two lanes lane change
broken, none open — nothing is written, which SUMO reads as open to everyone
solid, solid solid, curb emergency only
solid broken, broken solid decided by the half nearer the vehicle
<lane index="0" ... changeLeft="emergency"/>
<lane index="1" ... changeRight="emergency"/>

SUMO's attributes name the classes allowed to change, so a line nobody may cross is written as the short list of those who may cross it anyway.

A double line's two words are read looking along the road's reference line, left word first, as OpenDRIVE reads them: in solid broken the solid line is on the left. A vehicle obeys the line on its own side, so in a lane to the right of a solid broken line it may change across and in the lane to its left it may not — the asymmetry the marking exists for. The IR keeps markings by the side of the reference line, so a lane running against it has its left_marking on its right in travel; the exporter accounts for that, and for handedness, by working from where the lanes are.

changeLeft and changeRight are the driver's left and right in travel under either handedness. A left-hand network numbers its lanes from the kerb on the left, so there the next lane up is on the driver's right: with a solid line between two lanes driving on the left, lane 0 is written with changeRight and lane 1 with changeLeft — the mirror of the example above.

Two things open a change the paint would close. A lane that leads nowhere while the rest of its edge carries on — a lane drop — can only be left sideways, and netconvert refuses a prohibition that would trap a vehicle in it, so none is written. And two lanes that are neighbours in SUMO without sharing a boundary in the IR — a painted island between them that SUMO has no lane for — are kept apart as if by a solid line, since crossing the island is no lane change the map offers.

LaneSpec and Python's Lane default to a solid line on both sides, so a road built without saying otherwise closes every lane change on it. Give lanes that may be changed between a broken line on the side they share.

Right of way

SUMO's right of way is a matrix over pairs of movements — which stream gives way to which other stream — and a plain XML file has no way to state one: <request> is something netconvert computes and writes into the .net.xml. What the format does have is the edge priority ladder, the same one the OpenStreetMap export climbs — lifted one rung, so that even a footway has a rung below it to yield on.

So a RightOfWay rule moves the approach edges it names one rung apart, and the junction is marked rightOfWay="edgePriority":

<node id="j_t" x="0.000" y="0.000" z="0.000" rightOfWay="edgePriority"/>

That attribute is what keeps this from being a hint. Without it netconvert weighs the priorities against its own reading of the geometry, and a map that gives the stem of a tee right of way over the road across the top of it gets the road across the top anyway. With it, the numbers decide.

The rule names lanes, so the priority goes on the edges that carry them and not on the road: a rule about the northbound approach leaves the southbound carriageway where its road type put it.

What survives, then, is which approach holds right of way — and the tests check that by reading the state netconvert wrote on each movement, not the priority the export asked for. What does not survive is anything finer: whether a particular movement of the priority arm must still give way to an oncoming one is netconvert's decision, and sumo_warnings() says so.

A road whose cross-section changes becomes a chain of edges with a node between them, because an edge has one lane count from end to end. Which lane carries on into which is still the IR's: the connections it holds across the boundary are written as <connection>s through that node, and netconvert, given an edge's connections, adds none of its own — a lane with no connection onward ends at the boundary.

That leaves the edge with no connection across: every lane on that side ends, say a driving lane that becomes a cycle lane. netconvert reads an edge with nothing listed as one whose movements are unspecified, and would guess some, carrying the driving lane on into the cycle lane. So the movements of such an edge onto every edge leaving the node it runs into are listed as deleted, <delete from="r.0.fwd" to="r.1.fwd"/>, and netconvert warns that the edge goes nowhere — which is what the map says. (A <connection from="r.0.fwd"/> with no target means the same to recent netconvert, but SUMO 1.18 still guesses past it; the deletions hold on both, and deleting a movement netconvert would not have built is silent.) The same holds for any edge the IR carries nothing on from, wherever it ends short of a dead end — except a footway, since pedestrians cross a node on its walking area rather than along connections, so there is nothing for netconvert to guess.

Stop lines

SUMO has no stop line as a thing of its own. What it has is a lane's stop offset: how far short of the end of the lane a vehicle waiting at the junction ahead comes to a halt. Without one it halts at the very end of the lane — for an arm that stops where the IR's junction begins, the junction's mouth rather than the line painted before it.

So each stop line is measured along every written lane it crosses, from where it crosses the lane's centreline to the lane's end in the direction of travel, and the distance becomes that lane's <stopOffset>:

<lane index="0" width="3.500" shape="-1.750,40.000,0.000 -1.750,14.000,0.000" disallow="pedestrian">
    <stopOffset value="8.000"/>
</lane>

It carries no vClasses: a stop line binds everything that drives up to it, which is SUMO's default of all. netconvert keeps a lane's custom shape as it was given rather than cutting it back to the junction, so the end it is measured from is the end the built network has, and the tests read the offset back out of the .net.xml and check that the point it puts the stop at is the painted line, to within a few centimetres.

The lanes measured are the ones the stop line names, and those that a rule naming it governs — a right-of-way rule's yielding lanes, a traffic-light rule's lanes — where the line actually crosses them. An offset is only meaningful where the lane runs into a junction, so on an approach split over several edges by a change of cross-section it goes on the edge that reaches the junction and nowhere else. A line within 10 cm of the end of its lane needs no offset, since that is where SUMO stops a vehicle anyway, so it leaves neither a <stopOffset> nor a trace link, and sumo_warnings() counts it apart from the lines that are written; and where two stop lines cross one lane, the one nearer the junction holds its one offset. The trace records each offset as merged into the lane, with the role stopOffset.

Where a stop line cannot become an offset, sumo_warnings() names it with the lane it misses and the reason: a stop line not drawn as a line across the road at all, a lane no SUMO lane is written for (a junction's connector), an edge that does not end at a junction, a line set back further than the last edge before the junction is long (so on an earlier edge of the approach, named on that edge's lane), a lane whose centreline it does not cross, a lane where it is at the start — drawn across both carriageways, it belongs to the other one — a lane where it is further back than its edge is long — which netconvert would refuse — or one whose offset a line nearer the junction already holds. It is named lane by lane, so a line drawn across both carriageways that is written on the approach is reported only for the lane leading away.

A line carries no direction, so which carriageway it belongs to is judged only where it is in doubt: for a line that crosses lanes running both ways, as one drawn across the whole road does. That is decided on the IR, before anything about SUMO edges or nodes: the crossing with each lane is projected onto the road's reference line, and the line belongs to a lane if it is nearer the junction the lane runs into than the junction it leaves, each measured along the road — every cross-section of it — and on through the roads joined to it end to end by plain joints, up to the first junction that way; a dead end has no junction beyond it. So a road joined to the next by a plain joint at one end and running into a junction at the other, or a stub with a dead end, is judged by the one junction it has, not by its own two ends. A lane for which the line is nearer the junction behind it is "at the start" of it, however the roads are cut into edges; exactly halfway, or with no junction either way, a lane a rule naming the line governs is preferred, and the others are at the start. Only the lanes the line belongs to are then placed as above — on the edge that reaches the junction, or reported as on an earlier edge, or as running into no junction. A line whose lanes all run the same way stands before wherever they run to, however short the road, so an approach split by a change of cross-section close to the junction keeps its stop line even when the last edge is shorter than the line is set back.

Junctions, which SUMO models the same way round as the IR

The IR draws a junction as a set of connector roads, one per movement. SUMO draws it as a node: the arms stop at its edge, and netconvert generates an internal lane for every connection across it. The two models agree about the thing that matters — the movements are enumerated, not guessed — so the connectors are not written as edges. Each becomes the <connection> saying its approach lane may be left for its exit lane, and the connector's centreline is written as the connection's shape:

<connection from="north.fwd" to="west.bwd" fromLane="0" toLane="0"
            shape="-1.750,14.000,0.000 -1.748,13.200,0.000 … -14.000,1.750,0.000"/>

netconvert takes a connection's shape as the shape of the internal lane it draws for it, so the path across the junction in SUMO is the curve the IR drew — the same one the OpenDRIVE connecting road and the Lanelet2 lanelet carry — and not one netconvert invented. A turn that must wait inside the junction for oncoming traffic is still split into two internal lanes at the point where it waits, but both lie along the connector. A movement through a chain of connectors gets their centrelines end to end, in the order it crosses them.

This is why the arms are left exactly where the IR puts them, 14 m short of the centre: the gap is the junction, and netconvert fills it along the IR's connectors. Nothing here moves geometry, which is the one thing the OpenStreetMap export has to do.

Footways are the exception. The IR lays a pavement round every corner of a junction — a connector from one arm's sidewalk to the next arm's — but SUMO's pedestrians do not walk along connections. They cross a node on a walking area that netconvert builds there, joining every footway that meets at the node, walked either way. So no connection is written between two footways, and the .netccfg sets walkingareas, which makes netconvert build one wherever footways meet rather than only where there is a pedestrian crossing. Each paved corner comes out as one walking area joining the same two sidewalks, and a pedestrian walks round it in either direction; the trace puts the pavement, and the connections into and out of it, in the junction's node: with the role walkingarea, because netconvert names the walking area itself. Writing the pavement as a connection would not work: which sidewalk faces a corner depends on which end of each arm meets the junction and on the side traffic keeps to, so a pavement can run from a sidewalk leaving the junction, and netconvert refuses a connection out of an edge that does not end there. sumo_warnings() counts the footway connections left to the walking areas.

A crosswalk becomes a pedestrian crossing. SUMO's crossing is not a strip with a place along the road, as the IR's is: it belongs to a node, and netconvert lays it across the mouth of the edges it names, between the walking areas either side. So a crosswalk near the end of a road — where the IR puts one at a junction, a little short of the mouth — is written as the crossing at that end's node, across both carriageways of the road:

<crossing node="j_x" edges="west.bwd west.fwd" priority="1" width="4.000"/>

"Near" is within 15 m of the road end, measured to the crosswalk's nearer edge; sumo_warnings() says how far the furthest one moved. Pedestrians have priority on every crossing, because a crosswalk in the IR is a painted one; at a signalised node the light decides instead (see Traffic lights). (priority is written as 1 rather than true because SUMO's schema types it as a number, and netconvert refuses true when it validates the file.) Two kinds of crosswalk are not written, and sumo_warnings() names each: one further along the road than that, a mid-block crossing, which SUMO could only carry by splitting the road with a node of its own there; and one across a road without a footway at both kerbs, which netconvert would build with nothing leading off one end. The trace names a crossing crossing:<node>/<edge>+<edge>, the edges sorted, since the plain format gives it no id; once the built network is loaded, the crosswalk also reaches the lane of the crossing netconvert built for it (:j_x_c0_0, role crossing), matched by the crossingEdges netconvert records on it.

Traffic lights

A junction with a traffic light on an approach — or on a lane a TrafficLight rule names — becomes a traffic_light node, and its light is named after it, so that the name follows from the map rather than from netconvert:

<node id="j_x" x="0.000" y="0.000" z="0.000" type="traffic_light" tl="j_x"/>

The IR holds no signal timing, and netconvert would happily make a program up — but its phases and its numbering of the movements would be netconvert's, moving whenever its version or its reading of the junction did, and nothing could say which slot of the program a light of the map controls. So the export decides the program itself and writes it to <name>.tll.xml: the phases, and the link index of every movement into the node, which fixes the position of each movement in every state string.

For the four-arm crossroads of the exporter's tests, one lane each way, the program written is exactly this (links 0–2 leave the north arm for east, south and west, and so on round, 3–5 from the east, 6–8 from the south, 9–11 from the west):

<tlLogic id="j_x" type="static" programID="0" offset="0">
    <phase duration="35" state="gGgrrrGggrrr"/>
    <phase duration="3" state="yyyrrryyyrrr"/>
    <phase duration="2" state="rrrrrrrrrrrr"/>
    <phase duration="35" state="rrrggGrrrgGg"/>
    <phase duration="3" state="rrryyyrrryyy"/>
    <phase duration="2" state="rrrrrrrrrrrr"/>
</tlLogic>
<connection from="north.fwd" to="east.bwd" fromLane="0" toLane="0" tl="j_x" linkIndex="0"/>
...

North and south go together, then east and west. The straights are protected; each left turn gives way to the oncoming traffic, and with one lane per exit a right turn gives way too, since it runs into the same lane as the left turn from opposite.

The program is a static heuristic — the one Autoware's lanelet2_to_sumo writes:

  • Approaches are grouped by axis: two share a phase only if they arrive on headings opposite to within 35° — so an approach and the one facing it run together and the crossing road waits, however acute the angle the roads cross at.
  • Each group gets a green of 35 s (25 s when a junction has more than two groups), then 3 s of yellow and 2 s of all-red.
  • In a green, a movement is permissive (g) if it turns across the oncoming traffic released with it, or if another released movement runs into the same lane; everything else released is protected (G), and the rest red.

"Across the oncoming traffic" is a left turn where traffic keeps right and a right turn where it keeps left. It is read off the geometry and the map's handedness — the swing from the lane a movement leaves to the lane it joins — and not off netconvert's own dir, so the program does not depend on netconvert being told which side the map drives on. A turn with nothing facing it, off the stem of a tee, is protected.

At a node with pedestrian crossings the built program is longer than the one written. netconvert accepts the export's program and extends it: it appends the crossings' links after the vehicle links, numbered on from the last of them, and splits each green to end it with a pedestrian clearance (a 35 s green becomes 30 s and then 5 s in which the crossings turn red), keeping the yellow and the all-red. The vehicle links and their indices are still the export's, and so is the order of green, yellow and red each of them goes through; the crossing slots are netconvert's, and the trace does not name them.

The tests build the network and check that netconvert kept exactly this program and these link indices — at a node with crossings, the vehicle links of it — then drive traffic through every movement in sumo.

No U-turns. Left to itself netconvert adds one at every dead end, so a vehicle could turn back at the far end of an arm where the OpenDRIVE and Lanelet2 maps of the same IR simply end the lane. The IR states no such movement, so the .netccfg sets no-turnarounds, and the network has the movements the map has and no others.

Random traffic: randomTrips.py weights

No U-turns means every unlinked road end is a dead end a car cannot turn back at, so a trip that starts on the carriageway running into one, or ends on the one running out of one, has no route. Alongside the network the export writes three edgedata files that tell randomTrips.py where trips may go:

file an edge is weighted when
<name>.safe.src.xml a trip may depart there: some connection leaves it
<name>.safe.dst.xml a trip may arrive there: some connection reaches it
<name>.safe.via.xml a trip may pass through there (--intermediate): both
netconvert -c network/demo_town.netccfg
python $SUMO_HOME/tools/randomTrips.py -n network/demo_town.net.xml \
    --weights-prefix network/demo_town.safe -o trips.xml -r routes.rou.xml --validate

A weighted edge carries its length, so long roads draw more trips than short ones; every other edge is written with 0, which randomTrips.py never draws. The weights are for passenger cars, its default vehicle class: an edge with no lane a car may use — a footway, a cycle track, a hard shoulder — is 0 in all three, where randomTrips.py left to itself would happily start a car on a footway. And only the largest connected part of the network is weighted, so that a trip is never drawn between two pieces no route could join — the two carriageways of a lone road with nothing at either end, for one, are two separate one-way strips once U-turns are gone.

The rule is per file rather than lanelet2_to_sumo's "both a way in and a way out for all three": on a tree-shaped network with dead-end arms, every arm edge has only one of the two, and that rule would weight nothing. Even so, within one part of the network a car cannot turn back onto the arm it came from, so keep --validate, which has duarouter drop the few trips left without a route. A network with no connection at all — one road, linked to nothing — has no edge a car can leave, and every weight is 0.

Where on the globe

The network is in the map's own metres about its origin — the generated configuration turns off netconvert's offset normalisation so that it stays that way, in the same coordinates as the other exports — and it is georeferenced all the same. The node file opens with a <location>, which netconvert carries into the .net.xml without moving a node:

<location netOffset="0.000,0.000" convBoundary="-2000.000,-2000.000,2000.000,2000.000"
          origBoundary="139.737907026,35.661974335,139.782092974,35.698025610"
          projParameter="+proj=tmerc +lat_0=35.68 +lon_0=139.76 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"/>

SUMO reads a network position as the projected position plus netOffset, so sumolib's net.convertXY2LonLat(x, y) hands back the latitude and longitude the IR puts a point at — the ones the Lanelet2 export writes. For a UTM map netconvert writes the offset to the centimetre, which leaves them less than a centimetre apart.

map projection projParameter netOffset
local_cartesian, mgrs transverse Mercator about the origin, +k=1+h/R for an origin h m up (R the ellipsoid's mean radius of curvature there) — at h = 0 the OpenDRIVE <geoReference> string, word for word 0,0
utm +proj=utm +zone=<zone> (+south below the equator) minus the origin's easting and northing

An MGRS map's metres are local Cartesian about its origin like any other — MGRS only changes the grid position the Lanelet2 export reports beside them — so it is written the same way as a local one.

A local map's metres are an east/north/up frame: a plane tangent to the ellipsoid at the origin's altitude, which the Lanelet2 export projects through. A metre on that plane is longer, measured on the ellipsoid beneath it, by 1 + h/R, so the transverse Mercator is scaled by that much — at unit scale, 2 km out from an origin 2000 m up, it would be 0.63 m off; scaled, it is 1.5 mm off. (The OpenDRIVE <geoReference> is not scaled, so above sea level the two strings differ by the +k.) What a 2D <location> cannot carry at all is a point's own height above that plane: a point z above it and d from the origin is placed d·|z|/R from where the Lanelet2 export puts it — 1.6 cm for 50 m at 2 km — and sumo_warnings() states that bound for the map whenever it reaches a centimetre.

What it cannot carry

  • Lane markings. Which line is painted between two lanes, and in what colour, has nowhere to go. Only whether it may be crossed survives, as above.
  • One width per lane. A tapering lane is written at its mean width along — the area of the lane divided by its length — and sumo_warnings() names it.
  • Superelevation. A SUMO lane is flat across. The heights along it survive.
  • Lanes traffic does not run along — borders, painted islands, parking bays — are dropped rather than written as something they are not.
  • Signs. A sign is an additional file, not part of the network.
  • Crosswalks far from either end of their road, and across a road without a footway at both kerbs, as above: a SUMO crossing belongs to a node and joins two footways.
  • A pairwise right-of-way matrix, as above: the IR can say which approach holds right of way and no more.
  • Signal timing. The IR has none, so a signalised junction runs on the fixed-time program above rather than one the map states.
  • The stop line's paint. Where it is survives as a stop offset, as above; a stop line with nowhere to go is named.
  • A speed-limit rule on a lane that is not written. The connector lanes inside a junction become internal lanes whose speed netconvert sets for the turn, and a lane of a type SUMO has no place for is dropped with its rule. Where rules disagree about a lane, the higher ones are dropped. sumo_warnings() names the lanes in each case.
  • Heights in the geo-reference. A <location> is horizontal only; the heights are the map's own z, as in every other export. The origin's altitude goes into a local map's projection scale rather than being lost, but each point's own height above the origin's plane cannot be, which sumo_warnings() bounds as above.

CARLA

CARLA is a driving simulator built on Unreal Engine. A map in it is two files that have to agree: an .fbx holding the surface the sensors see, and an .xodr holding the road network the traffic drives. This writes both, plus the descriptor CARLA's importer reads and a manifest naming the textures.

m.export_carla("Import/", name="Town01")
for warning in m.carla_warnings(name="Town01"):
    print(warning)
roadgen.fetch_textures("Import/Town01")     # optional; see Textures below
Import/
├── Town01Package.json          what CARLA's Import.py reads
├── Town01Package.py            the build script: everything below, in order
└── Town01/
    ├── Town01.fbx              the surface
    ├── Town01.xodr             the road network — same name, which CARLA insists on
    ├── Town01.obj              the surface again, for the pedestrian navigation mesh
    ├── Town01_TrafficLights.fbx  the lights, as props tagged TrafficLight
    ├── Town01_TrafficSigns.fbx   the signs, as props tagged TrafficSign
    ├── furniture.manifest      where each light and sign stands (JSON)
    ├── map_logic.carla         CARLA's map_logic.json, to be copied beside the .xodr
    └── Textures/
        ├── polyhaven.manifest  what to fetch, and where each file goes (JSON)
        └── CREDITS.md

Getting from that folder to a level that runs is half a dozen steps in the right order with the right environment — fetch the textures, copy the package into CARLA's Import/, stage the pedestrian navigation builder, keep every other package's descriptor there out of Import.py's way (it imports every .json it finds), run Import.py, give the level a sky, stand the lights and signs in it — so the exporter writes them down as a Python script beside the descriptor, with what it knows baked in:

m.export_carla("out/", name="Town01",
               carla_root="/opt/carla", engine="/opt/UE_5.5")   # or CARLA_ROOT / CARLA_UNREAL_ENGINE_PATH
python out/Town01Package.py            # imports it, adds the sky, says how to run it
python out/Town01Package.py --launch   # and starts the server on it

It removes what an earlier import of the same package left under Content/<Package>/ first: Unreal will not create a map's mesh assets in a folder that already holds a level of the map's name, so a second Import.py over the first fails the whole map group — logged, in the middle of a few thousand lines — while the props, the .xodr and the level are all replaced, and what runs is the old road under new everything else.

Run it with the interpreter that has roadgen and CARLA's own carla module, which Import.py needs. --carla and --engine override what was baked in, --no-sky, --no-furniture and --no-textures skip those steps.

The .fbx and the .xodr share a name because CARLA requires it in three separate places: Import.py pairs them by name when it generates a descriptor itself, it copies the .xodr to Content/<Package>/Maps/<name>/OpenDrive/<name>.xodr so that the level and its road network agree, and UOpenDrive::LoadXODR finds the file by the level's own name at runtime. The .xodr is written by the OpenDRIVE exporter rather than by this one — two writers for one format would be two chances to disagree about the map CARLA drives and the map it draws.

A tag is a mesh name

This is the thing worth knowing about CARLA before writing anything for it, and it is not in the documentation — it is in MoveAssetsCommandlet.cpp.

CARLA's semantic segmentation does not read a mesh, a material or a property. It reads the folder the imported asset ended up in:

  /Game/<Package>/Static/<Folder>/<Map>/<Mesh>
   0     1         2       3        4      5
                           ▲
                           └── ATagger::GetLabelByPath reads this, and only this

and the import puts an asset in a folder by matching its name against six substrings. So the name of a mesh inside the FBX is its semantic ground truth, spelled at one remove. The grammar is RoadRunner's, which is what CARLA was built against:

  <mapName>_<meshType>_<meshSubtype>_<ordinal>
What roadgen writes Folder Tag Stencil
Town01_Road_Road_0 Road Roads 1
Town01_Road_Marking_0 RoadLine RoadLines 24
Town01_Road_Sidewalk_0 SideWalk Sidewalks 2
Town01_Road_Curb_0 SideWalk Sidewalks 2
Town01_Road_Gutter_0 SideWalk Sidewalks 2
Town01_Terrain_Ground_0 Terrain Terrain 10

Get it wrong and nothing fails. The mesh imports, the map loads, the camera renders — and the pavements come back labelled as ground. Map.carla_warnings() exists mostly for this, and so does the picture: the CARLA panel is coloured by running each mesh name back through CARLA's own classifier, so a mesh drawn in the wrong colour is one that will segment wrongly in the simulator.

Three things found by reading the commandlets rather than the documentation, each of which will quietly ruin a map, and each of which carla_warnings() reports:

  • The match is Contains, in a fixed order, and Terrain is tested bare and third. Every mesh name begins with the map's name — so a map called TerrainTown puts its pavements, kerbs and gutters in the Terrain folder, and keeps its road surface, because Road_Road is tested first. It is exactly the kind of failure that survives a screenshot.
  • Anything unrecognised becomes Terrain. The classifier's last arm is not "leave it alone", it is Terrain. A typo in a mesh name does not fail to import; it segments as ground.
  • light and sign are rejected outright. ValidateStaticMesh drops any mesh whose name — or whose material's name — contains either word, case-insensitively, before it is placed. A road called Sign Street takes its whole surface with it, and nothing is logged.

The surface

Six classes, from the cross-section the generator already produced — the real one, lane by lane, sampled along the reference line, so a road that tapers or gains a lane is measured where it actually is:

   grass        pavement          road surface         pavement       grass
  ┌──────┐┌───────────────┐┌─────┬─────────────┬─────┐┌────────────┐┌──────┐
  │verge ││   sidewalk    ││gut- │   driving   │gut- ││  sidewalk  ││verge │
  │      ││               ││ ter │             │ ter ││            ││      │
  └──────┘└───────────────┘└─────┴─────────────┴─────┘└────────────┘└──────┘
            ▲             ╲                           ╱             ▲
            │              ╲ curb                curb ╱             │
            └─ raised by the kerb height ──────────────┘            └─ Terrain

A lane stands where the IR lifts it — its height, station by station — and a sidewalk the IR left flat is raised by the kerb height instead. Either way a lane above its neighbour puts a vertical face between them — that face is the curb, and the strip of road at the foot of it is the gutter, split off the outermost part of the carriageway the way a real one is. Neither is a lane in the IR and neither should be: they are what a surface has and a road network does not. Painted lines are geometry rather than texture — a broken line is a strip per dash, cut by arc length so that a three-metre dash is three metres however the sampler laid its vertices. The surface is what the road's pavement says it is — asphalt, concrete, cobbles, gravel, dirt, sand, rock — changing material where the pavement changes.

Beyond the outermost band is the land: one mesh, from every road's edge out to 150 m past the network (ground_extent), for a lidar to reach and a vehicle that leaves the road to land on. It is a constrained Delaunay triangulation, not a grid, because a grid cannot meet a road's edge and the land has to: its constraints are the roads' own outer edges, vertex for vertex the points the surfaces end on, so no triangle crosses a road and what is inside a road's outline is cut away. Next to each edge the land slopes down over the verge (verge_width, 8 m) to the ground's height, and past the verges its vertices are a height field (ground_cell apart) whose height at each vertex is the lowest nearby road's, a little under, so it is flat where the roads are flat and slopes gently between roads at different heights. It is not terrain, and it is not pretending to be — a road network says nothing about the shape of the country it runs through, and generating hills here would be inventing a terrain model rather than deriving one. CARLA's editor is still where a map gets hills.

One mesh, meeting every edge exactly, is the point. A grass strip per road laid over a grid under all of them is two surfaces wherever they overlap and a step wherever they do not quite meet: the strips of neighbouring roads cross at a junction, a strip round a tight corner folds over itself, and a lidar return or a wheel finds every seam. A road through a junction has land on one side of it at most — the side that faces away from the junction — and a raised pavement facing the junction gets a kerb face down to the road level instead, which the land meets at the foot. And a vertex of the land goes in only where it really is a verge's width from every road's edge — for a toe, every edge but the two chords of its own rail it stands on — which is the one rule that keeps ledges out of it: the toe of a verge on the inside of a bend tighter than the verge is wide has folded back over the rest of its rail, and one pushed out from a road in a junction lands beside the pavement round the corner; either is nearer than a verge to some edge and is left out. The land is tagged Terrain and is the one mesh of that class.

Pedestrians

CARLA's walkers go where its navigation mesh says they can, and that mesh is not built from the level: Import.py runs RecastBuilder over an .obj of the map, in which every triangle is labelled by the material it wears — road, sidewalk, crosswalk, grass, or a block — and a walker spawns on a sidewalk, crosses at a crosswalk, and keeps off the rest. So the package carries the surface twice: the .fbx the level is built from, and a <name>.obj in CARLA's frame (y up, the roadgen y negated) whose materials are those labels. The tool that reads it is one the UE5 branch builds but does not put where Import.py looks, so the build script finds it under Build/ and stages it, with the .obj, into Util/DockerUtils/dist/; the navigation comes out as Content/<Package>/Maps/<name>/Nav/<name>.bin, which the server hands to clients and world.get_random_location_from_navigation() draws on.

Two things about that .obj are not obvious and both were found the hard way. The rasterizer does not let the last label win: where two surfaces lie within a climb (0.3 m) of each other it keeps the higher area id — sidewalk 1, crosswalk 2, road 3, grass 4 — whatever the order. A crosswalk laid over the road it crosses would become road, which a walker that does not cross roads is never routed over: so the road under each crossing is cut out of the .obj, and there only the crossing is left. And the crosswalk bands in it are not the painted stripes: they are one quad per crossing, crosswalk from kerb to kerb and CROSSWALK_OVERLAP (0.3 m) onto each pavement, because the stripes are what a camera sees and the band is what a walker needs. Only that far onto the pavement: CARLA's walkers stop and look for a light wherever their route steps from a pavement onto a crossing, and a band over the whole width of the pavement would stop everyone walking along it.

Crosswalks are painted, too — a bar and a gap of half a metre each, across the carriageway, from the crossing objects in the IR — and so are stop lines, as a bar across the lane on the traffic's side of the line. Both are tagged RoadLines like every other marking, so the walkers cross, and the vehicles halt, on something the cameras can see.

Where the town goes, and why it is a choice

CARLA's map import has exactly six names in it. UMoveAssetsCommandlet sorts a map's meshes into Road, RoadLine, SideWalk and Terrain and nothing else, and then UPrepareAssetsForCookingCommandlet places into the world exactly what it finds in those four folders. A building can be in the map or correctly tagged, and the import pipeline will not do both — so the package's script does the placing itself.

m.export_carla("Import/", buildings="in_map")   # the default
m.export_carla("Import/", buildings="props")
m.export_carla("Import/", buildings="omitted")
Placed by Tagged
in_map Import.py Terrain (10)
props roadgen.carla_furniture Buildings (3)

in_map is the default, because a wrong label on a building is recoverable in the editor while a town that depends on one more step is a town that is sometimes not there. props writes a second .fbx and declares it in the descriptor's props array with tag: "Building" — which is the one place in the whole pipeline where a tag is stated rather than spelled into a mesh name — and lists every building in furniture.manifest, so the same editor run that stands the traffic lights stands the town, tagged as a town. The pedestrians' navigation mesh carries the buildings either way.

Facades

The IR's building is a massing model — an outline, walls, a roof, a storey count and the street it faces — and by design it has no windows: a facade's openings are neither parts nor roofs, and no format the IR is written to has a word for them. A camera does not care. To a camera a wall with no windows is a warehouse, and a street of them is a street of warehouses whatever the grammar called them.

So the CARLA surface puts them in. Each wall is divided into bays at the pitch its building kind uses — a house every 3 m, a shed every 4.5 — every storey of every bay gets a window sized for that kind, a shop's ground storey is a shopfront, and the ground storey of the wall that faces the building's street gets a door in its middle bay. Each opening is two quads a couple of centimetres proud of the wall, a frame and the glazing inside it, so the wall's own quad and the building's semantic tag are untouched. The rhythms are in roadgen-carla/src/facades.rs, one per kind the presets emit; a kind the grammar made up is built like a house.

The sky, which the import does not give you

CARLA's importer builds every map on its BaseMap, and in the UE5 branch that level has a sun, a sky light and nothing for either to light: no atmosphere, no fog, no exposure. A map imported through Import.py renders its surfaces and, above them, black. CARLA's towns get their sky from a BP_Carla_Sky actor the weather system drives — but that actor is not in the BaseMap, and dropping one into an imported level does not give it the daylight the towns have, because those are lit by per-map weather sublevels with baked lighting (Town10HD_Opt/Weathers/T10HD_Day) that an imported map has not got.

roadgen.carla_sky("/opt/carla", "Town01Package", "Town01")   # after Import.py

runs the editor once more, on the level Import.py made, and gives it a plain Unreal daylight: a sky atmosphere and height fog, a physical sun of 75 000 lux in place of BaseMap's dim one, a sky light that captures the sky it now has, and an unbound post-process volume that turns auto exposure on — the project leaves it off, and without it a physical sun is a white frame. Running it twice changes nothing. python -m roadgen sky … is the same thing from a shell. What it does not do is hook the sky into set_weather(), which will store its parameters and move nothing: the only sky that API knows how to move is the one the towns have. The sun's position is an argument here instead.

Traffic lights and signs, which CARLA would otherwise put in the road

CARLA spawns traffic lights of its own. Its ATrafficLightManager reads every <signal> in the .xodr and, for each light, puts a BP_TLOpenDrive blueprint at the signal's position — and a signal written where the IR holds the light is five metres over the middle of the lane, so what appears is a pole in the carriageway. The stop, yield and speed-limit signs get the same treatment with BP_Stop01 and its kin. And a light mesh of one's own in the map's FBX never arrives: ValidateStaticMesh drops any mesh whose name holds light or sign before it is placed.

So the furniture is built, and it is built to the road: a pole on the pavement, kerb_setback in from the kerb on the side the governed traffic keeps to; a mast arm from there, perpendicular to the road, long enough to reach arm_overhang past the middle of the farthest lane the light governs; a head hung from it over the middle of every governed lane, facing the traffic. The head is the IR's: its housing stands on the light's bar (the bar is the housing's bottom edge), as wide as the bar and as tall as the head's height, with a lamp at each of its bulbs, and the arm runs just above it. So the housing and lamps CARLA shows are the traffic_light and light_bulbs ways the Lanelet2 map gives Autoware, to the centimetre. A sign is a post on the same pavement, sign_setback_along before the line it applies to, with an octagon, a triangle, a disc or a square on it for what the code says it is. A road with no pavement stands both on the verge instead.

Then three files have to agree, and the exporter writes all three:

  • The props. Town01_TrafficLights.fbx and Town01_TrafficSigns.fbx, declared in the descriptor with the tags TrafficLight and TrafficSign. That is what a segmentation camera reports them as (7 and 8), and it is also why they are props: the import's four map folders have no such class. A light is two meshes in that file — the post, with the arm and the heads' housings, and the three lamps of each head on their own — for a reason given below.
  • The .xodr. Each signal is written at the foot of its post — t, zOffset and a <positionInertial> — with its s where it applies: at the stop line of the rule that names it, else at the bar. CARLA builds a light's stop boxes three metres before s, so a light whose rule has a stop line set back from the mouth halts traffic at that line rather than under its own heads. Every light is in a <controller>, one per traffic-light rule (the lights of a rule switch together) and, for a light no rule names, one per approach; each junction names the controllers that are its own. A stop, yield or speed-limit sign is written in the German catalogue CARLA reads (206, 205, 274 with the limit as its subtype and value), whatever spelling it was added with — stop, de205, de274-50, speed_limit_50, R1-1. Any other code goes through verbatim.
  • map_logic.carla. CARLA's map_logic.json, which its InitializeTrafficLights looks for beside the .xodr: with it there, CARLA spawns no lights of its own and instead turns the actor within fifty centimetres of each listed signal into a working ADigitalTwinsTrafficLight — the signal's id, the junction's controller, the stop boxes, and lamps driven through any material named TrafficLight that has an Emissive Intensity. It is carried under another name because Import.py takes every .json under Import/ for a package descriptor.

Import.py imports the props and places nothing, so the package's script runs the editor once more:

roadgen.carla_furniture("/opt/carla", "Town01Package", "Town01")   # after Import.py

reads furniture.manifest, spawns each prop at the foot of its post facing its traffic, copies map_logic.carla beside the .xodr as map_logic.json, gives the lights' lamp slots instances of CARLA's own M_TrafficLights so they switch, sets each prop to collide as its triangles rather than as the convex hull the importer generated (the hull of a pole and an arm is a wedge across the lane), and — for each stop, yield and speed-limit sign — stands a bare ATrafficSignBase in the matching state at the post, which is what CARLA's SpawnSignals looks for before spawning a plate of its own. Running it twice places once. python -m roadgen furniture … is the same from a shell.

Two details of CARLA's adoption decide how the props are placed. It rebuilds the actor it adopts by copying each mesh component's relative transform onto a new actor at the old one's location, so a StaticMeshActor — whose mesh is its root — would be placed twice over; every prop is therefore a bare actor with a scene root and the mesh as a child. And the copy is made after CARLA has tagged the level, so the adopted mesh segments as nothing: hence the split, with the lamps standing exactly at the signal as the actor CARLA finds, and the post a metre outside its search, untouched and tagged. Only the lamps go unlabelled.

export_carla(furniture=False) leaves all of it out: the signals stay where the IR put them and CARLA improvises, which carla_warnings() says. Two things it also says that are CARLA's rather than this exporter's: a light on a lane that leads into no junction gets a controller of its own that CARLA runs alone, logging that it cannot apply the timing; and a 110 km/h sign gets CARLA's plate spawned beside ours, since CARLA has a model for that limit and no sign state to match a placed one against.

Textures

The surfaces are scanned PBR materials from Poly Haven, published under CC0, so a generated town can ship with photographic surfaces and no licence attached to them.

Nothing is fetched while exporting. An exporter that reached for the network could not run offline, could not run in CI and could not run in the browser — and the demo page runs this crate compiled to WebAssembly. So the package references its textures and carries a manifest saying exactly which Poly Haven asset, at which resolution, belongs at which path; roadgen.fetch_textures(folder) reads that manifest and downloads them.

A package with no textures in it is still a complete, importable CARLA package: every material carries a flat colour that stands in for its texture. And CARLA replaces them all anyway when use_carla_materials is on, which it is by default — its road materials are built for its lighting and its sensors, and a map that wears them looks like the maps it will be benchmarked against. Pass use_carla_materials=False to see the package's own.

Lane markings have no texture and are not meant to: a painted line is flat white or flat yellow, its edge comes from the geometry, and scanned paint would tile visibly down a straight line. The two marking materials are colours — and M_RoadMarking_Yellow is named that way on purpose, because PrepareAssetsForCooking picks CARLA's yellow lane material for the slot whose name holds Yellow.

The slugs in the manifest are the ones roadgen was written against, and Poly Haven's catalogue is theirs to change. A slug that has gone is reported by name rather than guessed at, and the manifest is JSON in the package: swapping one is editing a file.

Coordinates, which is the part that can silently be wrong

Three conventions have to line up and only two of them are roadgen's.

roadgen is right-handed: x east, y north, z up, metres. CARLA and Unreal are left-handed — carla::geom::RightHandedVector3D says so in as many words, and MapBuilder.cpp negates the y of every OpenDRIVE point it parses under a comment reading Unreal Y axis hack. Unreal's FBX importer closes the gap: with bConvertScene set, which CARLA's import settings do set, it converts the frame the file declares into Unreal's, negating Y on the way.

So the mesh is written in roadgen's own frame, unflipped, and declared Z-up right-handed; the importer flips it, CARLA flips the .xodr the same way, and the two land on top of each other. Flipping Y here as well would flip it twice, and a map mirrored about its own centreline is a map whose roads all turn the wrong way — which looks plausible enough in a screenshot to survive a review. Units are metres, declared as such, and CARLA's importer scales them into Unreal's centimetres itself.

The FBX is written in text rather than binary. Binary FBX is smaller and imports faster; it also needs DEFLATE for its array records and ends in an undocumented footer block the SDK checks. Text needs neither, so this crate has no compression dependency, no magic constants, and produces a file that can be opened in an editor and diffed when a map comes out wrong.

A point-cloud map for Autoware, from the same surface

Autoware localizes by matching its LiDAR against a point-cloud map (NDT), and a generated map has no survey to take one from. It does have the meshes CARLA's level is built from — which are exactly what the simulated LiDAR sees — so the point-cloud map is sampled from those:

m.export_carla("Import/", name="Town01")
m.export_lanelet2("autoware/Town01/lanelet2_map.osm")
m.export_pointcloud_map("autoware/Town01", name="Town01")
autoware/Town01/
├── lanelet2_map.osm
├── pointcloud_map/
│   ├── 0_0.pcd                 one grid cell each: binary, x y z as 32-bit floats
│   ├── 0_-1.pcd
│   └── …
└── pointcloud_map_metadata.yaml

That is Autoware's divided point-cloud map, which its map loader reads for partial loading: x_resolution and y_resolution are the cell size, and each cell's file name maps to the corner it starts at (0_-1.pcd: [0, -20] covers x 0–20, y −20–0). Run Autoware with pointcloud_map_file:=pointcloud_map.

  • The frame is the Lanelet2 map's. Every point is put through the conversion the Lanelet2 exporter gives its nodes' local_x and local_y — the map's own metres, or metres within the MGRS square for an mgrs map, worked out point by point as the nodes are — and its height is the map's z, which is the nodes' ele. The two files agree because they are converted by the same code.
  • The surface is export_carla's. name, buildings, kerb_height, verge_width and ground_extent mean the same things and should be given the same values. The town is in the cloud wherever it goes — in the map, or as props the package's script stands in the level — because it is in the level either way. The traffic lights and signs are not.
  • spacing (0.5 m) is how far apart samples are along a triangle's edges, and the size of the voxel that keeps one of the points that land in it: triangles share edges and the roads lie over the land, so the same place is sampled many times. It is finer than the voxels Autoware downsamples the map and its scans to. cell_size (20 m) is the side of a cell, in whole metres: Autoware's loader reads each corner as an integer, and a map with one it cannot read is not loaded at all.

What it cannot carry

Map.carla_warnings() reports all of it. Beyond the tagging traps above and whichever trade the town was exported under:

  • A junction is written once per movement through it. A junction in the IR is a connector road per turn, and each carries its own carriageway, so the middle of a junction is coplanar with itself and will z-fight. CARLA's own maps have a single junction surface, which a road network has no way to describe.
  • The furniture is placed by the script, not by the import. Until carla_furniture has run, the level has the signals and none of the meshes; and the sign codes CARLA has no meaning for stand, segment as signs and govern nothing.
  • Roughness maps are fetched but not wired up. FBX's material model is Phong, which has a shininess exponent and no roughness map. The files are in the package for a material rebuilt in the editor.

ClipGT

ClipGT is the scene format NVIDIA's Cosmos world-scenario tooling reads: a directory of Parquet files named {clip_id}.{layer}.parquet, one row per element.

clip_id = m.export_clipgt("clips/", scenario="scenario.yaml")
Layer What goes in it
lane each drivable lane's two rails, in travel order
lane_line one row per painted cross-section edge, with its colour and style
road_boundary the outermost edge on each side of every cross-section
crosswalk, wait_line crossings and stop lines
traffic_light, traffic_sign position, orientation and category
intersection_area a junction's outline
egomotion_estimate, calibration_estimate see below

Coordinates are FLU — x forward, y left, z up — which is the same shape of frame as the IR's east-north-up, so they are written through unchanged, heights and all. A rail on a graded, banked road carries the elevation and the cross-fall the generator computed for it; nothing here is projected to the horizontal plane.

It is a scene format, not a map format. A reader will not look at a directory at all unless it holds an egomotion table, so a map on its own cannot be written as a clip — something has to drive through it, and something has to be looking.

The scenario file

Where the vehicle drives and what is bolted to it are not properties of the map: the same network should be drivable several ways, with several rigs, without editing it. So both come from a YAML file.

clip_id: town
frame_rate: 30.0
speed: 12.0                    # metres per second

route:
  start: lane/north/0          # set off here and follow successors
  # lanes: [lane/north/0, ...] # or drive exactly these, in this order

sensors:
  - name: camera:front_wide_120fov
    position: [1.7, 0.0, 1.45]       # metres, in the rig's forward-left-up frame
    roll_pitch_yaw: [0.0, 0.0, 0.0]  # degrees
    width: 1920
    height: 1080
    fov_degrees: 120.0               # an ideal equidistant f-theta lens
  - name: camera:rear_tele_30fov
    position: [-0.9, 0.0, 1.3]
    roll_pitch_yaw: [0.0, -1.5, 180.0]
    width: 1920
    height: 1080
    cx: 955.0                        # defaults to the middle of the frame
    polynomial: [0.0, 1830.0, 0.0, 0.0, 0.0, 0.0]
    polynomial_type: angle-to-pixeldistance   # the default
    linear: [1.0, 0.0, 0.0]                   # the affine term, identity by default

examples/clipgt-scenario.yaml is this file, kept in the repository and loaded by a test so that it cannot quietly stop working.

Everything is optional and anything left out keeps the value it already had, so a scenario can say only what it wants to change. Arguments passed to export_clipgt override the file; arguments left out keep it. An unknown key is an error, not a silence: a scenario is written by hand, and a speed_kph that should have been speed is worth being told about.

fov_degrees is a convenience, not a calibration: it produces the textbook equidistant fisheye r = f·θ with f chosen so the horizontal half-angle lands on the edge of the frame. A measured lens goes in polynomial instead.

A sensor's name is prefixed with camera: if it has no prefix, because a reader discovers cameras by that prefix and skips anything else. Each one also gets a {clip_id}.{camera}.json of frame timestamps, so a reader that syncs poses to frames finds the frames the track was actually generated at rather than resampling.

With no sensors, the calibration table still holds a rig — an empty one, which is what the IR knows about cameras on its own. The clip loads; there is just nothing to render from, and clipgt_warnings() says so.

What it cannot carry. ClipGT has no topology: a lane is two rails, with no successor, no predecessor and no junction movement, so a clip is a picture of the roads rather than a network you can route on. Element ids are the Parquet row index, so the IR's stable identifiers do not survive either. Map.clipgt_warnings(scenario) reports that rather than letting a caller assume a round trip, and checks the scenario's route against the map while it is there. The pole, road_island and road_marking layers have no counterpart in the IR and are not written.

GPUDrive

GPUDrive is a GPU-accelerated driving simulator that loads scenes from JSON: one file holding a map as polylines and the agents driving it as logged tracks.

m.export_gpudrive("scene.json", scenario="examples/gpudrive-scenario.yaml")

There is no published schema for that file. What there is is the simulator's reader, src/json_serialization.hpp, and the data model in roadgen-gpudrive is shaped by it field for field: every key it insists on, every key it will do without, and the strings it compares types against.

Element What goes in it
lane each drivable lane's centreline, in travel order
road_line a painted boundary between lanes, with its Waymo line code
road_edge the edge of the drivable surface, and a kerbed median
crosswalk the outline of a crossing
stop_sign a single point, where a stop sign applies
objects one track per agent: position, heading, velocity and validity per timestep

map_element_id is the Waymo Open Motion feature code the simulator's MapType is built from, so a solid single white line goes out as 7 and a median kerb as 16. The markings the IR carries map onto it directly; a broken-and-solid pair painted white has no code there and goes out as ROAD_LINE_UNKNOWN rather than as a colour it is not.

GPUDrive is a plane. A position is an (x, y) in metres and the document has no z anywhere, so the elevation, grade and superelevation the generator computed are dropped at the boundary. Distances along an agent's route are measured in plan view for the same reason: an agent asked for 10 m/s covers ten metres of ground a second, not ten metres of a climbing road.

Two decisions do not fall out of the IR on their own. An edge is one element: either the boundary of the drivable surface or a painted line, never both, because the IR has one curve there and writing it twice would put two elements on top of each other. And a junction connector has no edges — its lane centreline is written, because that is the path through the intersection, but its boundaries are not, since a road_edge is something an agent collides with and there is no wall down the middle of a junction.

The scenario file

A scene is a map and the agents driving it; a generated map has no logged traffic, so the agents come from a file.

name: town
scenario_id: town-0001
steps: 91                  # timesteps; 91 is GPUDrive's episode length and its maximum
time_step: 0.1             # seconds between them

agents:
  # The first agent is the scene's self-driving car: `sdc_track_index` is 0.
  - type: vehicle          # vehicle, pedestrian or cyclist
    speed: 12.0            # metres per second, held for the whole route
    route:
      start: lane/north/0          # follow successors from here
      # lanes: [lane/north/0, ...] # or drive exactly these, in order
    length: 4.6            # metres; left out, an agent is the ordinary size of its kind
    width: 2.0
    height: 1.6
    track_to_predict: true # listed in the scene's tracks_to_predict; the default
    difficulty: 0
  - type: cyclist
    speed: 4.5
    mark_as_expert: true   # replayed from the track rather than handed to a policy
    of_interest: true      # listed in the scene's objects_of_interest

examples/gpudrive-scenario.yaml is this file, kept in the repository and loaded by a test so that it cannot quietly stop working.

Everything is optional and anything left out keeps the value it already had. Arguments passed to export_gpudrive override the file; speed and route apply to the first agent, which is the scene's own vehicle. An unknown key is an error, not a silence.

An agent drives its route at a constant speed and stands at the end of it for whatever is left of the episode, rather than vanishing — every timestep is valid, because an agent that is there for the whole scene is what the others have to deal with. Where its track ends is its goalPosition, which is what the simulator scores a policy against.

What it cannot carry. A scene holds no lane topology: a lane does not say what it leads to, so an agent's route is baked into its track rather than routed at load time. There is no traffic-light element and no stop-line element, so signals and the phases they govern are dropped — and of signs, only the stop sign has anywhere to go. Lane widths go with them: a GPUDrive lane is a centreline. Road element ids are the row index in the written file, so the IR's stable identifiers do not survive either. Map.gpudrive_warnings(scenario) reports all of that rather than letting a caller assume a round trip, and checks the scene against the reader's fixed buffers while it is there — 515 objects, 956 road elements, 1746 vertices an element and 91 timesteps, past which the simulator silently drops what does not fit. The speed_bump and driveway elements go the other way: the IR has nothing that means either, so they are never written.

The demo page

https://hakuturu583.github.io/hdmap_generator/

An editor on the left, a viewer on the right. Write roadgen, press Run, and the viewer fills with what the script wrote; the switch above it flips between the formats — in the same frame, so what changes is the format and nothing else. That is the argument the rest of this README makes, made in one screen: one description of a road network, seven exports, and what each format could and could not carry written under each picture.

Whichever format is showing, its files are listed under the switch with their sizes, a source toggle and a save button, so the bytes behind the picture are a click away.

It is the package, not a demonstration of the package. The page loads Pyodide — CPython built for WebAssembly — and installs roadgen's own wheel into it, cross-compiled to wasm32-unknown-emscripten. The import roadgen on the page is the import roadgen everywhere else, so there is no second implementation to keep in step and nothing that can be right in the demo and wrong in the library. The generation runs in the tab: no file is uploaded, no server does the work, and the page keeps working with the network unplugged once it has loaded.

The script is run in a directory of its own and then the directory is looked at. The tabs are not a fixed set — they are whatever turned up — so a script that exports one format gets one tab. Neither the page's code nor its stylesheet names a format: recognising a new one is a line in driver.py, the file that already knows which reader to hand a written export to. The format you were looking at survives a re-run, which is what makes it possible to change one line and watch one format.

Which viewer draws what

Export Drawn by Why
Lanelet2, OpenStreetMap Leaflet and osmtogeojson Both are OSM XML in latitudes and longitudes. A map library already knows what to do with that, and puts the result on the Earth rather than on a blank sheet.
OpenDRIVE, SUMO, CARLA, ClipGT, GPUDrive roadgen-viewer, in this repository There is no off-the-shelf browser viewer for these that an Apache-2.0 project can ship.

roadgen-viewer reads the written file — with the same libraries that wrote it, and never the IR — and draws it as SVG. That is what makes the pictures worth looking at: a panel can only be right if the file is. It is not browser-only, either; it is in the wheel, so a notebook can draw an export too:

from IPython.display import SVG
m.export_opendrive("map.xodr")
SVG(roadgen.render_opendrive("map.xodr"))       # or render_sumo, render_carla, render_clipgt, render_gpudrive

OpenDRIVE is the interesting one to draw. It does not hold a road as a shape: it holds a reference line as a chain of lines, arcs and clothoids, and every lane as a polynomial width measured sideways from it. So the picture is evaluated rather than read, which means it goes wrong in exactly the ways the geometry can — and a lane that lands in the wrong place on the page is a lane in the wrong place in the file. It is also the only one of the four that draws buildings, because it is the only one of the four whose format has anywhere to put them. Every part is drawn, not just the outline: a wing behind a house and a tower set back on a podium are both things you would see from above. An object with no outline is not drawn at all — OpenDRIVE lets one say "a building, this wide and this long", and a box inferred from two numbers is a guess rather than a footprint.

Everything is a plan view. The heights, the grades and the superelevation are in the files and not on the screen, and every drawing says so in its own description — one clause, written once where the SVG is written — rather than leaving a reader to assume the map is flat.

Building it

# The wheel, for Pyodide rather than for this machine.
pip install pyodide-build==0.39.0
pyodide xbuildenv install --url \
  https://github.com/pyodide/pyodide/releases/download/0.28.3/xbuildenv-0.28.3.tar.bz2
source /path/to/emsdk/emsdk_env.sh      # Emscripten 4.0.9
USE_LEGACY_PLATFORM=1 \
  CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_RUSTFLAGS="-C link-arg=-sSIDE_MODULE=2" \
  RUSTUP_TOOLCHAIN=nightly-2026-09-17 \
  pyodide build -o dist

# The page, which is the wheel plus Pyodide plus three JavaScript libraries.
cd web && npm ci && node build.mjs --wheel ../dist/*.whl
node smoke.mjs                          # opens site/ in headless Chromium

Four versions are pinned to each other and none of them floats: Pyodide fixes the Python version and the ABI tag the wheel must carry, its cross-build environment fixes the Emscripten version, and the Rust toolchain has to satisfy both — stable cannot link this target, because emcc rejects the mangled names it emits. The same numbers appear once each in .github/workflows/pages.yml and web/build.mjs, with a note beside each saying what breaks if it moves.

The published page is self-contained. Pyodide, Leaflet, osmtogeojson and CodeMirror are all copied in at build time — from npm and from a GitHub release, pinned by web/package-lock.json — so the page makes no request to a CDN and what ships is what the lockfile says. The one thing it does fetch at runtime is OpenStreetMap's map tiles, and only for the two panels that have a place on the Earth to show.

Publishing needs Pages enabled for the repository with GitHub Actions as the source. A pull request builds the page and runs it in a browser; only main deploys.

Validation

validate() reports everything wrong at once, rather than failing on the first problem. It checks identifier uniqueness, dangling references, lane/road agreement, road-link reciprocity, that linked roads actually meet in space, that a connection leaves and enters by the ends traffic really uses, that connected lanes' centrelines and boundaries are continuous in XYZ, that a lane's boundaries are the right way round and span its width, junction membership, and the map's coordinate metadata.

Each exporter adds the constraints its own format imposes, through roadgen_opendrive::check and roadgen_lanelet2::check (Map.format_warnings() from Python). Those constraints stay on the exporter's side of the boundary and are never pushed back into the IR.

Reading OpenDRIVE back

m = roadgen.read_opendrive("map.xodr")
for note in m.read_warnings():
    print(note)
m.export_lanelet2("map.osm")
m.export_sumo("sumo/")
let imported = roadgen_opendrive::read("map.xodr")?;
for note in &imported.approximations {
    eprintln!("{note}");
}
let map = imported.map.validate()?;
roadgen_lanelet2::write(&map, "map.osm")?;

The exporter's table — road to <road>, lane to <lane>, connection to <laneLink> — read the other way. Nothing is regenerated: the reference line is rebuilt piece for piece from <planView> (a line, an arc and a spiral as themselves, a <paramPoly3> as the cubic it is), the widths from <width>, the movements from the lane links and the junctions, the lights, signs, stop lines, crosswalks and buildings from the signals and objects, and the controllers and priorities as rules. The lane boundaries are then laid out by the same code the builder uses for a road it generated, so a file roadgen wrote comes back with the boundaries it was written from, and a file from somewhere else gets the boundaries roadgen would have given it. The map that comes back is an UnvalidatedMap, and validate() is the same gate every generated map goes through.

The IR is narrower than OpenDRIVE in a few places, and where it is, the reader approximates and says so — Imported::approximations in Rust, read_warnings() in Python, one line per thing the map now says less exactly than the file did:

  • a <width> that is a straight run or the IR's own smooth ease is read exactly; any other cubic is sampled into straight runs;
  • a width that reaches zero is held at a centimetre, because a WidthProfile cannot reach zero;
  • an <elevation> that curves inside one plan-view piece is cut into straight grades at the sampling length, since every piece of the IR climbs at one grade; a <paramPoly3> cannot be cut and takes one grade end to end;
  • a lane type the IR does not have — stop, median, bus, the ramps — lands on the nearest it does, and a road type likewise;
  • a building's roof is flat, because an outline has one height per corner;
  • a signal or object that names no lanes is read as governing every lane of its section that runs the way it faces;
  • a traffic light that names the lanes inside a junction — CARLA's Town maps stand a junction's light on a post valid on no lane of its own road and refer to it with a <signalReference> from each connecting road — is read as governing the approaches that feed those lanes instead: the IR's traffic light, like Autoware's, is about the lanes that stop for it, and a regulatory element on the lanelets inside the junction is one a vehicle on the approach never sees. A sign keeps the lanes it names, since what a sign means is its code, which the reader passes through without reading;
  • a <controller> is one traffic-light rule, with the stop lines of each junction mouth its lanes enter by: the document's own where any cross that mouth's lanes (the one across most of them first), and otherwise object/stopline/<road>/<end>, a line the reader draws across the lanes where they enter the junction, from the driver's left to the right — one per mouth however many controllers stop there. Lanes that enter no junction stop at a document line across them, or at none. A map roadgen wrote with a traffic-light rule that has no stop line gains one the same way when it is read back, so that map comes back with one object more.

Every lane of a traffic-light rule gets one traffic_light regulatory element in the Lanelet2 export. A rule with no stop line or one is a single element over all its lanes, with that line as the ref_line whichever lanes the line itself names. A rule with several is one element per line, on the rule's lanes that line names and referring to the lights over them; a lane no line names — a mouth whose document line crosses only some of its lanes — gets an element of its own without a ref_line. The SUMO export stops lanes at a line by the same rule (Map::traffic_light_stops), and no element is written that no lanelet holds.

What is an error rather than a note is a document that is not a road network: a reference line with a gap in it wider than 10 cm (a narrower one is closed and reported), a link to a road that is not there, a lane link to a lane the neighbour does not have. A <geoReference> the reader does not recognise places the map at latitude 0, longitude 0 and says so; origin= overrides it.

Roads and junctions are named after the file's ids, so the file's road 12 is road/12; a signal, object or building whose name is one of the IR's own identifiers gets it back, which is what makes a map that goes out and comes in keep its object names. A map read from a file cannot be added to — add_road and the rest refuse — because it has no builder behind it: roadgen generates maps and reads them, it does not edit them.

tests/integration/tests/reimport.rs writes every scenario, reads it back and writes it again, and asks the independent evaluator whether every lane edge of the second document is where the first put it. It is.

Agreement between OpenDRIVE and Lanelet2

The test suite exports each scenario, reads the OpenDRIVE back with the opendrive crate's parser and the Lanelet2 map back with simple_lanelet2's loader, and compares:

  • Topology. Lanelet2 has no successor tag — a routing graph rediscovers connectivity from shared boundary points — so tests/integration asserts that the graph finds exactly the movements the IR holds, and that OpenDRIVE's lane links and junction connections account for the same ones.
  • Geometry. Every lane centreline is evaluated out of the OpenDRIVE document independently (<planView>, <elevationProfile>, <laneOffset>, widths) and compared with the IR's vertices, which are also the Lanelet2 map's vertices.

Where a map's reference lines are tangent-continuous the two agree to within a micrometre — and the generator makes them so: where two roads meet at an angle the corner is rounded into an arc before anything is written, so the joint that used to be the one place the formats disagreed is now a place they agree. two_roads_that_meet_at_an_angle_are_rounded_so_the_formats_agree pins that down. The mitre the IR applies where road ends meet is still there for the joints that remain — a junction's arms, and roads that meet along one tangent — and is what makes their boundary points the same points rather than merely nearby ones.

Following an element across formats

Every format numbers its output its own way. OpenDRIVE numbers roads in the IR's order; Lanelet2 and OpenStreetMap count; SUMO names edges after roads and counts lanes from the kerb; ClipGT and GPUDrive have nothing but the row. None of those numbers flow back into the IR, so "which lanelet is OpenDRIVE road 4, lane -1?" has no answer in the files themselves.

So every export writes, beside what it wrote, a trace file — which element of the file each road, lane, junction, connection, object, rule and building of the IR became — and export_ir writes the IR's own elements, without their geometry. Loaded together, they follow one element from any format to any other, through the IR:

m.export_ir("out/map.ir.json")
m.export_opendrive("out/map.xodr")           # + out/map.xodr.trace.json
m.export_lanelet2("out/lanelet2_map.osm")    # + out/lanelet2_map.osm.trace.json
m.export_sumo("out/sumo/")                   # + out/sumo/demo_town.sumo.trace.json

t = roadgen.Trace.load("out/map.ir.json",
                       "out/map.xodr.trace.json",
                       "out/lanelet2_map.osm.trace.json",
                       "out/sumo/demo_town.sumo.trace.json")
t.to_ir("lanelet2", 1000123)                 # [{"ir": "lane/north/0", ...}]
t.translate("lanelet2", 1000123, to="sumo")  # [{"ref": "lane:north.fwd_0", ...}]
t.translate("sumo", "north.fwd_0", to="opendrive")

Formats are never mapped onto each other directly: a lanelet goes back to the IR lane it came from, and that lane forward into SUMO. Each export only has to record its own side, a trace written today joins one written tomorrow, and a format added later needs one more trace and changes none.

Format Trace file What an element is called
OpenDRIVE <file>.trace.json road:4, lane:4/0/-1 (road / section / lane), junction:16, connection:16/2, signal:3, object:5, outline:9/0, controller:0, bridge:7, tunnel:8
Lanelet2 <file>.trace.json lanelet:1001004, linestring:1001000, regulatory_element:1001050
OpenStreetMap <file>.trace.json way:-12, node:-3, relation:-40
SUMO <dir>/<prefix>.sumo.trace.json edge:north.fwd, lane:north.fwd_0, node:j_x, connection:north.fwd_0>west.bwd_0
ClipGT <dir>/<clip id>.clipgt.trace.json <layer>:<row> — lane:17, lane_line:3, wait_line:0
GPUDrive <file>.trace.json road:42, agent:1
CARLA <dir>/<name>.carla.trace.json the package's .xodr as OpenDRIVE, and actor:<name> for each light and sign; the meshes are not traced

A file read has a trace too. read_opendrive records which element of the map each road, lane, junction, signal, object and controller of the file became, in the same kinds as the OpenDRIVE export, and write_read_trace writes it beside the file (direction: "import"). That is how a signal of somebody else's OpenDRIVE — a CARLA traffic light, by its OpenDRIVE id — is followed to the SUMO movements it switches:

m = roadgen.read_opendrive("Town10HD_Opt.xodr")
m.export_ir("town.ir.json")
m.write_read_trace()                         # + Town10HD_Opt.xodr.read.trace.json
prefix = m.export_sumo("sumo/")

t = roadgen.Trace.load("town.ir.json", "Town10HD_Opt.xodr.read.trace.json",
                       f"sumo/{prefix}.sumo.trace.json")
t.translate("opendrive", "signal:949", to="sumo")   # [{"ref": "tls:j_189/12", ...}, ...]

The read trace is format opendrive too, so one Trace holds either it or an OpenDRIVE export's trace, not both. A document that gives two signals one id has both traced to that signal:.

A light is traced to the movements off the lanes it stands over — the approaches, for a CARLA light the file names over its junction's connecting roads. The lights of one OpenDRIVE <controller> are read as one rule over all their lanes, which is right for the program, but each light still answers only for its own movements, and for those its rule gives it that no light stands over. A stop line the reader drew, having no element in the file, is traced collapsed to the controller it was drawn for, with the role stop_line.

A bare element takes the kind that format is usually asked about — a lanelet id is a lanelet, a SUMO id a lane — so 1000123 and "lanelet:1000123" are the same question.

One element is not always one element. A road is two SUMO edges; a boundary is shared by two lanelets; a junction connector has no SUMO lane of its own and becomes a connection. Every link says which: exact, part (the IR element was split and this is a piece), merged (several IR elements were written as one) or collapsed (the IR element was absorbed into this one). Answers therefore come as a list. When the target format has nothing for any IR element the source came from — a lane, asked of OpenStreetMap, which writes roads — the lookup steps one neighbour out in the IR dump (the lane's road) and says so in via.

A trace is refused rather than trusted. Each one records a digest of every file it describes and a fingerprint of the IR it was written from. A file written again since — whose counters may now name different things — or a trace from another map is an error when loaded, not a wrong answer later. trace=False on any export skips the file; check_files=False on Trace.load reads one whose files are elsewhere.

SUMO's internal lanes. netconvert draws a lane across the junction for every connection, and names it itself (:j_x_0_0), so the export cannot. The built network says which connection each carries, and those are in the trace:

t = roadgen.Trace.load("out/sumo/demo_town.sumo.trace.json")
t.add_sumo_net("out/sumo/demo_town.net.xml")
t.to_ir("sumo", ":j_x_0_0")                  # the junction connector it runs along

A network is taken only if it was built from the export the trace describes: the same edges, lanes and connections, and a junction where the export's .nod.xml put each node. A network of another map whose roads happen to share their names is refused.

A turn that waits inside the junction gets two internal lanes, and both trace to the same connector. Every internal lane traces to something: the exported .netccfg tells netconvert not to add the U-turn it otherwise puts at every dead end, which the IR — and so every other format — does not have. A network built without that configuration gets them back, and they trace to nothing.

In Rust, the exporters' *_traced functions return the roadgen_core::trace::Trace, and roadgen-trace writes it (write_trace, write_ir) and joins it (TraceIndex). The design, and why it is shaped this way, is in docs/ir-trace-design.md.

Layout

roadgen/
├── crates/
│   ├── roadgen-core/        canonical IR, generation, validation
│   │   ├── topology/        connectivity
│   │   ├── geometry/        3D curves, alignments, frames, profiles, sampling
│   │   ├── layout/          a cross-section laid out against a reference line
│   │   ├── semantics/       lane types, rules, markings, objects
│   │   ├── buildings/       footprints, heights, storeys
│   │   ├── id/              typed identifiers
│   │   ├── trace/           what an export wrote for each IR element
│   │   └── validation/      UnvalidatedMap → ValidatedMap
│   ├── roadgen-buildings/   a town beside the roads, from a CGA shape grammar
│   ├── roadgen-carla/       meshes, FBX and the package CARLA UE5 imports, and
│   │                        Autoware's point-cloud map of the same surface
│   ├── roadgen-opendrive/   lowering onto the `opendrive` crate, and reading it back
│   ├── roadgen-lanelet2/    lowering onto `simple_lanelet2`
│   ├── roadgen-clipgt/      lowering onto ClipGT's parquet layers
│   ├── roadgen-gpudrive/    lowering onto GPUDrive's scene JSON
│   ├── roadgen-osm/         lowering onto plain OpenStreetMap XML
│   ├── roadgen-sumo/        lowering onto SUMO's plain-XML network
│   ├── roadgen-trace/       the IR dump, trace files, and lookups across them
│   ├── roadgen-viewer/      reading the exports back, and drawing them as SVG
│   └── roadgen-python/      PyO3 bindings
├── examples/                ClipGT and GPUDrive scenario files
├── web/                     the demo page: roadgen in a browser, through Pyodide
├── python/roadgen/          the Python package
├── tests/
│   ├── integration/         scenarios and cross-format checks
│   └── python/              the Python front end
├── Cargo.toml
├── pyproject.toml
└── LICENSE

Building and testing

cargo test --workspace          # Rust: unit, export round-trip, scenarios
cargo clippy --workspace --all-targets
cargo fmt --all --check

maturin develop                 # build and install the Python extension
python -m pytest tests/python

cd web && npm ci && node smoke.mjs   # the demo page, in a browser

The SUMO tests run SUMO. netconvert builds the exported network and sumo loads it, so what they check is not the exporter's opinion of what it wrote. Install it with apt install sumo (and pip install sumolib for the Python side); without it those tests skip, saying so. Setting ROADGEN_REQUIRE_SUMO=1 turns the skip into a failure, which is what CI does.

Releasing

Every merge is a release. Every PR carries exactly one of release:major, release:minor or release:patch — the release-label check fails without one, and it is a required check on main, so a PR cannot be merged until it says how far it moves the version; there is no label for "not at all", because a change that is not worth a patch release is not worth merging. When a PR lands on main, the release workflow looks at every commit since the last release, bumps the version by the highest level any of their PRs asked for (a patch for a commit no labelled PR accounts for), commits chore(release): vX.Y.Z, tags it, writes a GitHub release with generated notes, runs the tests again on the tagged commit, builds the wheels and the sdist, and publishes them to PyPI. Releases run one at a time, so two PRs that land together become one release rather than a race.

The version lives in three files that have to agree — pyproject.toml, the [workspace.package] in Cargo.toml (which is what roadgen.__version__ reports) and Cargo.lock — and tools/bump_version.py is the only thing that should edit them:

tools/bump_version.py patch      # 0.1.0 -> 0.1.1, printed to stdout
tools/bump_version.py 0.4.2      # set exactly

Two other ways in. Pushing a vX.Y.Z tag by hand builds and publishes that commit directly — which is how the first release goes out at the version the files already carry, and how a hotfix does — and it fails unless the tag is the version in the files. Running the workflow by hand (workflow_dispatch) builds the wheels and the sdist as artifacts and publishes nothing, for looking at a release before making it.

PyPI is reached through Trusted Publishing, so there is no token in the repository's secrets. It is set up once on PyPI's side: Publishing → Add a new pending publisher with owner hakuturu583, repository hdmap_generator, workflow release.yml and environment pypi, and a pypi environment on the repository (Settings → Environments). Until that exists the publish step fails and the wheels are still there as workflow artifacts — nothing reaches PyPI without a deliberate step outside this repository.

The release commit is pushed to main by the workflow itself, and main requires the label check on every update. A repository owned by a user cannot let the Actions app bypass a ruleset — only a repository role can — so the push is made with RELEASE_TOKEN, a fine-grained personal access token of the owner (Settings → Developer settings → Fine-grained tokens, this repository only, Contents: read and write) stored as a repository secret of that name; the owner is an admin, and the ruleset lets admins bypass it. Without the secret the release job stops and says so before anything is pushed.

Licence

Apache-2.0. See LICENSE and NOTICE.

Dependencies are kept to licences an Apache-2.0 project may redistribute — the OpenDRIVE data model and writer (opendrive, MIT), the Lanelet2 model, OSM I/O and projections (simple_lanelet2, BSD-3-Clause), and Arrow and Parquet (arrow/parquet, Apache-2.0), plus MIT/Apache-2.0 transitive crates.

The demo page ships three more, copied in at build time and pinned by web/package-lock.json: Leaflet (BSD-2-Clause), osmtogeojson (MIT) and CodeMirror (MIT). Their licence texts are copied in beside them, into site/vendor/. Pyodide (MPL-2.0) goes in whole, from its own release.

The ClipGT layer names and field names were read off the public clipgt_loader.py. That file is NVIDIA's and carries a proprietary header; none of it is reproduced here, only the names two programs have to agree on to exchange data.

The GPUDrive scene fields were read the same way, off the simulator's own reader, src/json_serialization.hpp, and its init.hpp and types.hpp beside it. Nothing from those files is reproduced either — what is written down here is the shape of the document they accept. Check licence compatibility before adding a dependency.

Metadata

Release files for roadgen 0.30.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for roadgen 0.30.0
File Size Uploaded
roadgen-0.30.0.tar.gz 906.8 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for roadgen 0.30.0
File Interpreter ABI Platform
roadgen-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.9 abi3 Linux glibc 2.17+ x86-64 Details
roadgen-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.9 abi3 Linux glibc 2.17+ ARM64 Details

Total release size: 11.6 MB

Release files / roadgen-0.30.0.tar.gz

Download URL roadgen-0.30.0.tar.gz
Size 906.8 kB
Tags Source
SHA-256 checksum
How to use checksums
132df64c458e5a72bed9e5e4c5ee6d0a45ff478777c09f7223e397db172330d1
BLAKE2b-256 checksum
How to use checksums
a6637833fc18af181bf9597499746bc9a0ac9ce968d86da1be207bd0232fca3f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / roadgen-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL roadgen-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.6 MB
Tags CPython 3.9 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
fe7cb8c63f831cc88e59245420378ad32e8e7c950dbbff436bda2cf8d38adf81
BLAKE2b-256 checksum
How to use checksums
4f1a5c7d1a79bb2ebee9cf96b9c6b057ff75403b889e4a3d83d5ef13138a80ea
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / roadgen-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL roadgen-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 5.1 MB
Tags CPython 3.9 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
87d9fa78c7822d10091e641d32c41993269edf60afa42ad6768e08b3fa060579
BLAKE2b-256 checksum
How to use checksums
7c83824354784552779cf723b42d0a2a570f62a66f8da090ec15bc9c99fc6ab1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.32.2

3 release files

0.32.1

3 release files

0.32.0

3 release files

0.31.0

3 release files

This release

0.30.0 This release

3 release files

0.29.2

3 release files

0.29.1

3 release files

0.29.0

3 release files

0.28.0

3 release files

0.27.0

3 release files

0.26.0

3 release files

0.25.0

3 release files

0.24.0

3 release files

0.23.0

3 release files

0.22.1

3 release files

0.22.0

3 release files

0.21.0

3 release files

0.20.0

3 release files

0.19.0

3 release files

0.18.1

3 release files

0.9.2

3 release files

0.9.1

3 release files

0.9.0

3 release files

0.8.0

3 release files

0.7.0

3 release files

0.6.0

3 release files

0.5.4

3 release files

0.5.3

3 release files

0.5.2

3 release files

0.5.1

3 release files

0.5.0

3 release files

0.4.0

3 release files

0.3.2

3 release files

0.3.1

3 release files

0.3.0

3 release files

0.2.1

3 release files

0.2.0

3 release files

0.1.0

3 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page