Metadata-Version: 2.4
Name: roadgen
Version: 0.12.0
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: GIS
License-File: LICENSE
License-File: NOTICE
Summary: Generate 3D road networks and write them as OpenDRIVE, Autoware Lanelet2, OpenStreetMap, SUMO, CARLA, ClipGT and GPUDrive
Keywords: opendrive,lanelet2,autoware,sumo,carla,hd-map,road-network
Home-Page: https://github.com/hakuturu583/hdmap_generator
License: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Demo, https://hakuturu583.github.io/hdmap_generator/
Project-URL: Homepage, https://github.com/hakuturu583/hdmap_generator
Project-URL: Issues, https://github.com/hakuturu583/hdmap_generator/issues
Project-URL: Repository, https://github.com/hakuturu583/hdmap_generator

# 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](#reading-opendrive-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](https://hakuturu583.github.io/hdmap_generator/)** — 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](#the-demo-page).

```python
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](#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](#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](#reading-opendrive-back). No other format is read, and
[`docs/import-feasibility.md`](docs/import-feasibility.md) says why.

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

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

### Design lineage

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

### What the types guarantee

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

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

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

### Identifiers

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

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

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

## Installing

### Python

```bash
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:

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

### Rust

```toml
[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" }
```

```rust
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](#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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.

```python
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.

```python
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`](https://crates.io/crates/symbios-shape) — decides what stands on
each one.

```python
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:

```text
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](https://github.com/TheJanusStream/symbios-shape) 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](#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](https://wiki.openstreetmap.org/wiki/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](#where-the-town-goes-and-why-it-is-a-choice), 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](#which-viewer-draws-what) 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.

```python
m.export_osm("map.osm")
```

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

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

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

### Junctions, which is where the two models disagree

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

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

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

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

### What it cannot carry

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

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

## SUMO

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

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

```python
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` |
| `<name>.safe.{src,dst,via}.xml` | `randomTrips.py` weights; see [Random traffic](#random-traffic-randomtripspy-weights) |

```bash
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:

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

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

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

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

### Left-hand traffic

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

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

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

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

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

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

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

### Lane changes

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

| marking between the two lanes | lane change |
| --- | --- |
| `broken`, `none` | open — nothing is written, which SUMO reads as open to everyone |
| `solid`, `solid solid`, `curb` | `emergency` only |
| `solid broken`, `broken solid` | decided by the half nearer the vehicle |

```xml
<lane index="0" ... changeLeft="emergency"/>
<lane index="1" ... changeRight="emergency"/>
```

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

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

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

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

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

### Right of way

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

### Random traffic: `randomTrips.py` weights

No U-turns means every unlinked road end is a dead end a car cannot turn back at, so
a trip that starts on the carriageway running *into* one, or ends on the one running
*out of* one, has no route. Alongside the network the export writes three
[`edgedata`](https://sumo.dlr.de/docs/Tools/Trip.html#customized_weights) files that
tell `randomTrips.py` where trips may go:

| file | an edge is weighted when |
| --- | --- |
| `<name>.safe.src.xml` | a trip may **depart** there: some connection leaves it |
| `<name>.safe.dst.xml` | a trip may **arrive** there: some connection reaches it |
| `<name>.safe.via.xml` | a trip may **pass through** there (`--intermediate`): both |

```bash
netconvert -c network/demo_town.netccfg
python $SUMO_HOME/tools/randomTrips.py -n network/demo_town.net.xml \
    --weights-prefix network/demo_town.safe -o trips.xml -r routes.rou.xml --validate
```

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

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

### Where on the globe

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

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

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

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

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

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

### What it cannot carry

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

## CARLA

[CARLA](https://carla.org/) 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.

```python
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
```

```text
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:

```python
m.export_carla("out/", name="Town01",
               carla_root="/opt/carla", engine="/opt/UE_5.5")   # or CARLA_ROOT / CARLA_UNREAL_ENGINE_PATH
```

```sh
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:

```text
  /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:

```text
  <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](#which-viewer-draws-what): the CARLA panel is
coloured by running each mesh name back through CARLA's own classifier, so a mesh drawn
in the wrong colour is one that will segment wrongly in the simulator.

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

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

### The surface

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

```text
   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.

```python
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.

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

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

```python
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](https://polyhaven.com),
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](#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:

```python
m.export_carla("Import/", name="Town01")
m.export_lanelet2("autoware/Town01/lanelet2_map.osm")
m.export_pointcloud_map("autoware/Town01", name="Town01")
```

```text
autoware/Town01/
├── lanelet2_map.osm
├── pointcloud_map/
│   ├── 0_0.pcd                 one grid cell each: binary, x y z as 32-bit floats
│   ├── 0_-1.pcd
│   └── …
└── pointcloud_map_metadata.yaml
```

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

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

### What it cannot carry

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

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

## ClipGT

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

```python
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.

```yaml
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`](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](https://github.com/Emerge-Lab/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.

```python
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`](https://github.com/Emerge-Lab/gpudrive/blob/main/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.

```yaml
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`](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](https://pyodide.org/) — 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](https://leafletjs.com/) and [osmtogeojson](https://github.com/tyrasd/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:

```python
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](#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

```bash
# 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`](.github/workflows/pages.yml) and
[`web/build.mjs`](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

```python
m = roadgen.read_opendrive("map.xodr")
for note in m.read_warnings():
    print(note)
m.export_lanelet2("map.osm")
m.export_sumo("sumo/")
```

```rust
let imported = roadgen_opendrive::read("map.xodr")?;
for note in &imported.approximations {
    eprintln!("{note}");
}
let map = imported.map.validate()?;
roadgen_lanelet2::write(&map, "map.osm")?;
```

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

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

- a `<width>` that is a straight run or the IR's own smooth ease is read exactly;
  any other cubic is sampled into straight runs;
- a width that reaches zero is held at a centimetre, because a `WidthProfile`
  cannot reach zero;
- an `<elevation>` that curves inside one plan-view piece is cut into straight
  grades at the sampling length, since every piece of the IR climbs at one grade;
  a `<paramPoly3>` cannot be cut and takes one grade end to end;
- a lane type the IR does not have — `stop`, `median`, `bus`, the ramps — lands on
  the nearest it does, and a road type likewise;
- a building's roof is flat, because an outline has one height per corner;
- a signal or object that names no lanes is read as governing every lane of its
  section that runs the way it faces.

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

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

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

## Agreement between OpenDRIVE and Lanelet2

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

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

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

## Following an element across formats

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

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

```python
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:

```python
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](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

```bash
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](.github/workflows/release-label.yml) 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](.github/workflows/release.yml) 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:

```bash
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](https://docs.pypi.org/trusted-publishers/),
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`](LICENSE) and [`NOTICE`](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](https://leafletjs.com/) (BSD-2-Clause),
[osmtogeojson](https://github.com/tyrasd/osmtogeojson) (MIT) and
[CodeMirror](https://codemirror.net/5/) (MIT). Their licence texts are copied in
beside them, into `site/vendor/`. [Pyodide](https://pyodide.org/) (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`](https://github.com/nvidia-cosmos/cosmos-transfer2.5/blob/main/cosmos_transfer2/_src/imaginaire/auxiliary/world_scenario/dataloaders/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`](https://github.com/Emerge-Lab/gpudrive/blob/main/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.

