The Course facade

The runtime utilities can be wired by hand — or bundled by track_gen.course.Course, which owns the orchestration invariants (rebake/resample/posts rebuild and a full progress reset on every regeneration; per-env respawns via a mask):

from track_gen import TrackGenConfig
from track_gen.course import Course, CourseConfig

course = Course(CourseConfig(
    mode="track",
    gen=TrackGenConfig(num_envs=E, device="cuda"),
    seeds=42,
    collision="segments",          # or "sdf" / None
    checkpoint_spacing=0.6,
))
course.bind(position=robot_pos, orientation=robot_quat, half_extents=robot_he)

track = course.generate()          # whole batch + coherent refresh
for _ in range(steps):
    sim.step()                     # writes the bound buffers in place
    res = course.step()            # events + contacts, no args
    course.reset(done_mask)        # respawn finished envs on the same course
course.generate(seeds=next_seed)   # new courses for everyone

generate() is whole-batch (the generator pipelines are fixed-batch captured graphs on CUDA — except generator="repulsive", which is not graph-capturable and runs eagerly every call, see Repulsive Generator); per-env control is reset(mask). The generators are deterministic under an unchanged RNG: calling generate() again WITHOUT seeds= reproduces the identical batch (plus a full progress reset), while passing seeds= advances the RNG for new courses. This reproduction is byte-identical per device for all generators, repulsive included (its analytic-adjoint gradient makes it bit-reproducible run-to-run on CUDA as well as CPU); only cross-device output differs, and then only statistically (fp32 rounding). In gates mode the same object wraps GateGenerator + gate progress + optional DiscChecker posts (post_radius > 0). The underlying tools stay reachable (course.collision, course.progress, course.checkpoints) and track_gen.set_capturing(True) flips the ONE shared capture flag used by collision, props, checkpoints, progress, and the facade itself, all at once, when you capture step() into your own sim graph. Once step() is captured, keep writing into the SAME bound buffers — rebinding after capture leaves the captured graph replaying against the old pointers (silently divergent results), so rebind only before (re)capturing.

In gates mode with post_radius > 0, course.step().contacts is a DiscContact instead of a BoxContact — same StepResult field, a different contact type depending on the course’s collision checker:

res = course.step()
hit = wp.to_torch(res.contacts.hit)      # DiscContact here, not BoxContact

In gates mode StepResult also carries a third field, res.frame — the TrackFrame localizing the agent on the gate CourseLine spline (s arc length, n signed right offset, n_up signed vertical offset, segment index). It is None in track mode.

Under the hood, two CUDA graphs do the heavy lifting on cuda devices: the generator’s own pipeline graph (captured on the first generate()) and a facade-owned refresh graph covering the post-generation work — SDF rebake, checkpoint resample, gate-post rebuild, and the full progress reset. When max_checkpoints is auto-derived, check course.checkpoint_sampler.truncated after regenerating onto much longer tracks.

API: track_gen.course.Course, track_gen.course.CourseConfig — see the API reference.