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. ALaneConnectionis 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 OpenDRIVEscoordinate, a lane offset — it goes throughFrame3::to_localor a method whose name sayshorizontal, so the projection is visible at the call site. Lines, arcs, clothoids, Béziers and polylines are allCurve3, 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 Lanelet2RegulatoryElementappears in the IR. A building is not one of these — it means nothing to traffic — so it lives inbuildingswith 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
eletag 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 ownz, 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, whichsumo_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, andTerrainis tested bare and third. Every mesh name begins with the map's name — so a map calledTerrainTownputs its pavements, kerbs and gutters in the Terrain folder, and keeps its road surface, becauseRoad_Roadis 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. lightandsignare rejected outright.ValidateStaticMeshdrops any mesh whose name — or whose material's name — contains either word, case-insensitively, before it is placed. A road calledSign Streettakes 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.fbxandTown01_TrafficSigns.fbx, declared in the descriptor with the tagsTrafficLightandTrafficSign. 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,zOffsetand a<positionInertial>— with itsswhere 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 befores, 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,274with 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'smap_logic.json, which itsInitializeTrafficLightslooks 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 workingADigitalTwinsTrafficLight— the signal's id, the junction's controller, the stop boxes, and lamps driven through any material namedTrafficLightthat has anEmissive Intensity. It is carried under another name becauseImport.pytakes every.jsonunderImport/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_xandlocal_y— the map's own metres, or metres within the MGRS square for anmgrsmap, worked out point by point as the nodes are — and its height is the map'sz, 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_widthandground_extentmean 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_furniturehas 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
WidthProfilecannot 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 otherwiseobject/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/integrationasserts 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.31.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| roadgen-0.31.0.tar.gz | 912.9 kB | Details |
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| roadgen-0.31.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.31.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.31.0.tar.gz
| Download URL | roadgen-0.31.0.tar.gz |
|---|---|
| Size | 912.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8d4c2bf27da77c54bbb67b910a983618dedf67f02ad0979a771f39d8406ebf9f
|
|
BLAKE2b-256 checksum How to use checksums |
1e6ad145860dfd75e34e2c738bdcb7c0210c215ad0a5a19637400cd7a81771da
|
| 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 logRelease files / roadgen-0.31.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | roadgen-0.31.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 |
96f8ae42b8261c93ea78185a9d4c5dec6e1dd9c8f17b53946e6e11a89caf91a6
|
|
BLAKE2b-256 checksum How to use checksums |
ba4866369dae30ffadb3027dc240c3c38698aaaf5d80e6a3807caab7834b10df
|
| 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 logRelease files / roadgen-0.31.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | roadgen-0.31.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 |
bc9c06023e20ecd931dc8d45b9304d451b49bdea7bed3f744d6f68774367bf16
|
|
BLAKE2b-256 checksum How to use checksums |
947b27f20446f0c430a6446c3ea682535d34c341db1eeeefff5e4d445b23e33d
|
| 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