Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3b59066
chore: attempted track generation based on random points and convex hull
amoghmpanhale Jul 16, 2026
75bffc6
feat(proc_track): track generator core (config + generator)
amoghmpanhale Jul 17, 2026
3840b6a
feat(proc_track): wall capsules + compiled model (INV-1)
amoghmpanhale Jul 17, 2026
7ba35a0
feat(proc_track): ProcTrackEnv + local projection
amoghmpanhale Jul 17, 2026
1716cc4
feat(proc_track): scripted driver, demos, package scaffold
amoghmpanhale Jul 17, 2026
3d0f46c
docs(proc_track): Coursera-style generation walkthrough
amoghmpanhale Jul 17, 2026
4627f2a
feat(procedural_track): initial attempts
amoghmpanhale Jul 17, 2026
ad3e9a0
Merge branch 'amoghmpanhale/Procedurally-Gen-Tracks' of https://githu…
amoghmpanhale Jul 17, 2026
135938e
fix: remove track_seed7.png from outside of users
amoghmpanhale Jul 17, 2026
1e49467
fix(proc_track): box walls follow track boundary, not world axes
amoghmpanhale Jul 17, 2026
ac6f08f
feat(proc_track_final): package-ready track generation
amoghmpanhale Jul 28, 2026
ed4f119
refactor(proc_track_final): number the pipeline stages into stages/
amoghmpanhale Aug 2, 2026
4b87367
Merge branch 'main' into amoghmpanhale/Procedurally-Gen-Tracks
bmabsout Aug 3, 2026
aa95c34
Merge remote-tracking branch 'origin/main' into amoghmpanhale/Procedu…
amoghmpanhale Aug 8, 2026
fa39889
feat(track_generation): procedural racetracks in the shared package
amoghmpanhale Aug 9, 2026
83e9f6a
chore: drop the proc_track prototypes superseded by track_generation
amoghmpanhale Aug 9, 2026
ae4ef54
docs(readme): document track generation
amoghmpanhale Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ venv/
MUJOCO_LOG.TXT

CLAUDE.md

# macOS
.DS_Store
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,16 @@ python3 -m pytest validation/ -v
│ └── meshes/ — visual STL meshes (cosmetic; mass="0")
├── src/neoracer_mujoco/ — importable package (the reusable toolbox)
│ ├── contract.py — the car contract (single source of truth)
│ ├── assets.py — cars() / load() model discovery + compile
│ ├── assets.py — cars() / tracks() / load() discovery + compose()
│ ├── sensors.py — SensorReadings/IMUReading/LidarScan + read()
│ ├── sim.py — compile/settle/run + physics probes
│ └── control/ — classical controllers (track_centering)
│ ├── control/ — classical controllers (track_centering)
│ └── track_generation/ — procedural racetracks (generate_track → Track → MJCF)
├── examples/
│ ├── run.py — viewer launch script (mjpython entry point)
│ ├── manual_drive.py — arrow-key teleop (game-style, python3 entry point)
│ └── track_centering_demo.py — PD centering controller on the corridor track
│ ├── track_centering_demo.py — PD centering controller on the corridor track
│ └── track_generation.py — generate procedural tracks, describe or drive one
├── validation/ — pytest physics + logic conformance suite
└── docs/ — (reserved) design notes and parameter log
```
Expand Down Expand Up @@ -96,6 +98,46 @@ python3 -m pytest validation/ -v
Steering command range is ±0.4 rad; the two Ackermann equality constraints split
it into the correct inner/outer front-wheel angles automatically.

## Track generation (`src/neoracer_mujoco/track_generation/`)

Two calls are the whole API — one makes the geometry, the other puts the car on it:

```python
from neoracer_mujoco import compose, generate_track

track = generate_track(seed=7, difficulty=2) # pure geometry, no MuJoCo
model = compose(track) # a compiled MjModel, car included

model = compose("straight_corridor") # or a hand-written assets/tracks/ XML
```

`generate_track(seed, difficulty)` returns a `Track`: N evenly spaced samples
around a closed loop, each with a centerline position, tangent, left normal,
curvature, and corridor half-width, plus the loop's total length. Same seed, same
track, always. `difficulty` runs 0 (gentle and wide) to 3 (tight and narrow); pass
a full `TrackSettings` instead if you want every knob.

Generation draws a candidate, checks it for corners tighter than the car can take,
a loop that doubles back and touches itself, and a total length outside the target
range — then nudges the control points behind each complaint and re-checks, rather
than redrawing the whole loop. A candidate that can't be repaired is thrown away
and a fresh one drawn; `TrackGenerationError` means the limits are asking for
something the generator can't draw.

Five files, split by pipeline stage:

| File | Role |
|---|---|
| `track.py` | the `Track` itself and the attempt/repair loop — **read this first** |
| `shape.py` | control points → spline → even resample → corridor width |
| `reject.py` | what makes a candidate invalid, and how to nudge it |
| `config.py` | the knobs (`TrackSettings`, `settings_for_difficulty`) |
| `mjcf.py` | `to_mjcf(track)` → scenery-only walls + floor; the only module here that touches MuJoCo |

Everything except `mjcf.py` is plain NumPy/SciPy with no simulator assumptions, so
the geometry is usable outside MuJoCo. `compose()` lives in `assets.py` rather than
here because it also serves hand-written tracks that never touch the generator.

## Usage / demo scripts (`examples/`)

`examples/` holds runnable, hackable demos — start here to drive the car yourself
Expand All @@ -120,6 +162,13 @@ stable API.
`python3 -m examples.track_centering_demo` (headless) or
`mjpython -m examples.track_centering_demo --viewer`.

- **`track_generation.py`** — generates procedural tracks and reports what came
out (loop length, corridor width, tightest corner, geom count) across seeds and
difficulties. `--save PATH` writes one as standalone MJCF; `--drive` puts the car
on it under the wall-following controller in the viewer.
`python3 -m examples.track_generation` (headless) or
`mjpython -m examples.track_generation --drive --seed 7 --difficulty 3`.

Sensor reading lives in the package, not the examples: import from
`neoracer_mujoco.sensors` (`read`, `wheel_speed_ms`, `print_sensors`,
`lidar_scan`) to log IMU / wheel / steer / suspension / LiDAR off a compiled model.
Expand All @@ -146,6 +195,11 @@ python3 -m pytest validation/ -v
and the Ackermann polyfit validated against exact arctan geometry.
- **`test_track_centering.py`** — `TrackCenteringController` sign/safety logic plus
one physics-integration run on the corridor track.
- **`test_track_generation.py`** — the generator's own rules (every track valid,
determinism, difficulty ordering, impossible limits raise) plus the MJCF layer's
conventions (wall material name, spawn clearance, geom budget). The slow one:
difficulty 0 burns most of its attempt budget per track, so it dominates suite
runtime (~45 s).

The car contract (expected actuators, required sensors, mass band, ctrl layout)
lives in `src/neoracer_mujoco/contract.py` — update it there if the contract changes.
3 changes: 3 additions & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ channels:
- conda-forge
dependencies:
- python=3.13
# From conda-forge rather than pip: track generation leans on CubicSpline and
# cKDTree, and the conda build links a proper BLAS.
- scipy>=1.13
- pip
- pip:
- -r requirements.txt
16 changes: 4 additions & 12 deletions examples/manual_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@

import sys
from dataclasses import dataclass
from pathlib import Path

_PROJECT_DIR = Path(__file__).resolve().parent.parent

# ── tuning knobs (feel is physical — turn these while driving) ─────────────────
# Rates are per second, so the feel is the same regardless of frame rate.
Expand Down Expand Up @@ -143,20 +140,15 @@ def load_model(xml: str | None):
"""
No arg -> compose the ramp course + car at runtime, so the track XML never
names (or depends on) the car file. A path -> load it as-is (e.g. the bare
car on a plane). The car is its own spec, so it carries its own meshdir;
no path juggling needed.
car on a plane).
"""
import mujoco

from neoracer_mujoco import compose

if xml:
return mujoco.MjModel.from_xml_path(xml)

scene = mujoco.MjSpec.from_file(
str(_PROJECT_DIR / "assets" / "tracks" / "ramp_course.xml")
)
car = mujoco.MjSpec.from_file(str(_PROJECT_DIR / "assets" / "neoracer.xml"))
scene.worldbody.add_frame().attach_body(car.body("car"), "", "")
return scene.compile()
return compose("ramp_course")


def main(xml: str | None = None) -> None:
Expand Down
15 changes: 4 additions & 11 deletions examples/track_centering_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
default, or with an optional interactive MuJoCo viewer.

Composes neoracer.xml onto assets/tracks/straight_corridor.xml at runtime
(same MjSpec.attach_body pattern as manual_drive.py's load_model — neither
XML file names or depends on the other), settles the car, then drives the
(neoracer_mujoco.compose — neither XML file names or depends on the
other), settles the car, then drives the
controller for a fixed number of steps and reports centering performance.

No walled corridor with a "true" centerline exists elsewhere in the repo, so
Expand All @@ -27,12 +27,12 @@

import argparse
import time
from pathlib import Path

import mujoco
import mujoco.viewer
import numpy as np

from neoracer_mujoco import compose
from neoracer_mujoco import sensors as sl
from neoracer_mujoco.control.track_centering import (
LEFT_BEAMS,
Expand All @@ -42,8 +42,6 @@
compute_signals,
)

_PROJECT_DIR = Path(__file__).resolve().parent.parent

STEPS = 3000
SETTLE_STEPS = 400
# Deliberate initial lateral offset (m), applied AFTER settling, toward the
Expand All @@ -61,12 +59,7 @@


def load_model() -> mujoco.MjModel:
scene = mujoco.MjSpec.from_file(
str(_PROJECT_DIR / "assets" / "tracks" / "straight_corridor.xml")
)
car = mujoco.MjSpec.from_file(str(_PROJECT_DIR / "assets" / "neoracer.xml"))
scene.worldbody.add_frame().attach_body(car.body("car"), "", "")
return scene.compile()
return compose("straight_corridor")


def _side_clearance(
Expand Down
120 changes: 120 additions & 0 deletions examples/track_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Generate procedural racetracks and drive one.

Usage:
python3 -m examples.track_generation # describe a few tracks
python3 -m examples.track_generation --save out.xml # write one as MJCF
mjpython -m examples.track_generation --drive # drive it in the viewer
mjpython -m examples.track_generation --drive --seed 7 --difficulty 3

Two calls are the whole API: generate_track gives you geometry, compose puts
the car on it. Everything below is reporting and a control loop around those.

NOTE: --drive needs mjpython on macOS, not plain python3 (the passive viewer
must own the main thread). The default headless mode runs under either.
"""

import argparse
import time

import mujoco
import mujoco.viewer
import numpy as np

from neoracer_mujoco import compose, generate_track
from neoracer_mujoco import sensors as sl
from neoracer_mujoco.collision import reset_if_wall_hit
from neoracer_mujoco.control.wall_following import (
WallFollowingConfig,
WallFollowingController,
)
from neoracer_mujoco.track_generation import to_mjcf

# Follow the right-hand wall at roughly a quarter of the corridor width, so the
# car has room on both sides even on the narrowest stretches (half_width_min is
# 0.35 m at the hardest difficulty).
DRIVE_CONFIG = WallFollowingConfig(follow_side="right", target_distance=0.25)


def describe(seed: int, difficulty: int) -> None:
"""Generate one track and print what came out."""
track = generate_track(seed, difficulty=difficulty)
model = compose(track)
# Curvature is 1/m, so its reciprocal is the radius of the tightest corner.
tightest_radius = 1.0 / np.abs(track.curvature).max()
print(
f" seed {seed} difficulty {difficulty}: "
f"{track.total_length:5.1f} m loop, "
f"corridor {2 * track.half_width.min():.2f}-{2 * track.half_width.max():.2f} m, "
f"tightest corner {tightest_radius:.2f} m radius, "
f"{model.ngeom} geoms"
)


def drive(seed: int, difficulty: int) -> None:
"""Put the car on a generated track and let the wall-follower run it."""
track = generate_track(seed, difficulty=difficulty)
model = compose(track)
data = mujoco.MjData(model)
dt = model.opt.timestep
print(
f"seed {seed}, difficulty {difficulty}: {track.total_length:.1f} m loop, "
f"{model.ngeom} geoms. Close the viewer window to exit."
)

controller = WallFollowingController(DRIVE_CONFIG)
with mujoco.viewer.launch_passive(model, data) as viewer:
viewer.cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING
viewer.cam.trackbodyid = model.body("car").id
viewer.cam.distance = 2.5
viewer.cam.elevation = -35

while viewer.is_running():
step_start = time.time()

control = controller.compute(sl.read(model, data), dt)
data.ctrl[0:4] = control.throttle
data.ctrl[4] = control.steer
mujoco.mj_step(model, data)

# Touching a wall resets the car to its spawn pose; clear the
# controller's history too or its D-term carries across the jump.
if reset_if_wall_hit(model, data):
controller.reset()

viewer.sync()
time.sleep(max(0.0, dt - (time.time() - step_start)))


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--difficulty", type=int, default=1, choices=range(4))
parser.add_argument("--drive", action="store_true", help="open the viewer")
parser.add_argument("--save", metavar="PATH", help="write the track as MJCF")
args = parser.parse_args()

if args.drive:
drive(args.seed, args.difficulty)
return

if args.save:
track = generate_track(args.seed, difficulty=args.difficulty)
with open(args.save, "w") as handle:
handle.write(to_mjcf(track))
print(f"wrote {args.save} ({track.total_length:.1f} m loop)")
return

# Same seed, four difficulties: the loop keeps its family resemblance while
# the corners tighten and the corridor narrows.
print(f"one seed across the difficulty range (seed {args.seed}):")
for difficulty in range(4):
describe(args.seed, difficulty)

print(f"\nfour seeds at difficulty {args.difficulty}:")
for seed in range(args.seed, args.seed + 4):
describe(seed, args.difficulty)


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ requires-python = ">=3.10"
dependencies = [
"mujoco>=3.9.0",
"numpy>=1.26.4",
# CubicSpline (track centerline) and cKDTree (self-touch check, replacing an
# O(N^2) pass that runs on every repair of every generation attempt).
"scipy>=1.13",
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mujoco>=3.9.0
numpy>=1.26.4
scipy>=1.13
pytest>=7.4
ruff==0.16.0
18 changes: 17 additions & 1 deletion src/neoracer_mujoco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@

The `contract` module is the single source of truth for what every NeoRacer
car XML must satisfy (actuator/sensor names, ctrl layout, mass band).

The `track_generation` subpackage generates closed-loop racetracks, and
`compose()` turns one into scenery the car can drive on::

from neoracer_mujoco import compose, generate_track
model = compose(generate_track(seed=0, difficulty=1))
model = compose("straight_corridor") # or a hand-written assets/tracks/ XML

Note `track_generation` (the subpackage, which makes tracks) versus `tracks()`
(the assets/tracks/ discovery function, which lists hand-written ones).
"""

from . import contract
from .assets import cars, load
from .assets import cars, compose, load, tracks
from .sensors import (
IMUReading,
LidarScan,
Expand All @@ -26,16 +36,22 @@
read,
wheel_speed_ms,
)
from .track_generation import Track, generate_track, settings_for_difficulty

__all__ = [
"IMUReading",
"LidarScan",
"SensorReadings",
"Track",
"calibrate_imu",
"cars",
"compose",
"contract",
"generate_track",
"lidar_scan",
"load",
"read",
"settings_for_difficulty",
"tracks",
"wheel_speed_ms",
]
Loading
Loading