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.