roadgen
Generate 3D road networks — with a town beside 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-buildings ◀── CGA shape grammar
│ `symbios-shape`
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.
roadgen-buildings is the one arrow pointing into the IR, and it is a generator
rather than an exporter — 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.
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 |
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 |
| 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. 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.
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>.netccfg |
the netconvert run that turns the three into a .net.xml |
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 right in the direction of travel, index 0 outwards.
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'
speed is the road's limit in m/s, or SUMO's own default for the OSM highway value
the road type maps to. 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.
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.
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:
<connection from="north.fwd" to="west.bwd" fromLane="0" toLane="0"/>
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. Nothing here moves geometry, which is the one thing the OpenStreetMap export has to do.
A junction with a traffic light on an approach becomes a traffic_light node.
netconvert generates the phases, because the IR holds no signal timing to write.
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.
What it cannot carry
- Lane markings. Which line is painted between two lanes, and in what colour, has nowhere to go.
- 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.
- Crosswalks and signs. A SUMO crossing belongs to a node and a sign is an additional file, not part of the network.
- A pairwise right-of-way matrix, as above: the IR can say which approach holds right of way and no more.
- The geo-reference. The network is in the map's own metres about its origin, and the generated configuration turns off netconvert's offset normalisation so that it stays that way — the same coordinates as the other four exports.
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 whose type is sidewalk is raised by the kerb height, which puts a vertical face
between it and the surface beside it — 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.
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
loader labels triangles in file order and the last label wins, so the meshes are
written in order of precedence — buildings and kerbs first, then the land, the
roads, the sidewalks and last the markings — so that where two meet along an edge
the one a walker should be on wins the voxels there. And the crosswalk bands in it are not the
painted stripes: they are one quad per crossing, crosswalk from kerb to kerb,
because the stripes are what a camera sees and the band is what a walker needs.
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, at the height the IR gave the bar; a
three-lamp head hung from it over the middle of every governed lane, facing the
traffic. 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.
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 right; 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 |
| 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 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.5.1
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.5.1.tar.gz | 582.4 kB | Details |
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| roadgen-0.5.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| roadgen-0.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
Total release size: 9.6 MB
Release files / roadgen-0.5.1.tar.gz
| Download URL | roadgen-0.5.1.tar.gz |
|---|---|
| Size | 582.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b8ce282eeee9d9b8d2180c48138606a3d7e1e0040d412b0daa8f84339316fd2c
|
|
BLAKE2b-256 checksum How to use checksums |
3a73d1eb8739ac1d5abe955df009362717d7dfe101ff9fc7cacaa4688eab5aca
|
| 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 2, 2026.
Transparency logRelease files / roadgen-0.5.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | roadgen-0.5.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 4.7 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
2b1642df8732f1695716d5f994a4172f09c20979f950545420d986bbfac24cf5
|
|
BLAKE2b-256 checksum How to use checksums |
c4c76b874adbea08b5075ef0eb6b2cb1ecf6deade92e3d97c067d2a8e475a7d5
|
| 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 2, 2026.
Transparency logRelease files / roadgen-0.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | roadgen-0.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 4.3 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
bfb3d5ae90d9f1b5eb1116f6c0d02367ac4ad75d0ce7c419f63142bcaa96a21a
|
|
BLAKE2b-256 checksum How to use checksums |
2f0f5c06a5346ec3a422ede4c3a30fccef11973aa67ab66cf75bff5a42dc5262
|
| 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 2, 2026.
Transparency log