diff --git a/assets/neoracer.xml b/assets/neoracer.xml
index 015e814..fa64acb 100644
--- a/assets/neoracer.xml
+++ b/assets/neoracer.xml
@@ -16,6 +16,11 @@
+
+
+
@@ -77,7 +82,10 @@
-
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/pygame_drive/drive.py b/examples/pygame_drive/drive.py
new file mode 100644
index 0000000..fdbf274
--- /dev/null
+++ b/examples/pygame_drive/drive.py
@@ -0,0 +1,162 @@
+"""
+Drive the NeoRacer with pygame — WASD or a gamepad. Input only lives here;
+all the MuJoCo work is in sim.py.
+
+Usage (plain python3, NOT mjpython — pygame owns the window):
+ python3 drive.py # ramp course
+ python3 drive.py ../../assets/neoracer.xml # bare car on a plane
+
+Keyboard: Gamepad (auto-detected, overrides keys):
+ W / S throttle / reverse right trigger throttle
+ A / D steer left / right left trigger reverse
+ Space hard stop left stick X steer
+ Backspace reset car (un-flip) Start reset car
+ T start/stop timer
+ R reset timer
+ Esc quit
+"""
+
+import math
+import sys
+from pathlib import Path
+
+import pygame
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from sim import DriveSim
+
+# ponytail: SDL axis numbers for an Xbox-style pad; other pads may need retuning
+STEER_AXIS = 0 # left stick X, +1 = right
+ACCEL_AXIS = 5 # right trigger, rests at -1
+BRAKE_AXIS = 4 # left trigger, rests at -1
+RESET_BUTTON = 7 # Start
+DEADZONE = 0.1
+
+
+LIDAR_MAX = 2.0 # m — beams past this read as "clear" (rangefinder returns -1 too)
+RADAR_R = 90 # px radius of the radar disc
+RADAR_PAD = 12 # px from the top-right corner
+
+
+def draw_lidar(screen, beams: dict[str, float]) -> None:
+ """Top-right radar: each beam a dot at its angle, distance = range,
+ color hot->cold with proximity so nearby clutter reads as a dense blob."""
+ cx = screen.get_width() - RADAR_R - RADAR_PAD
+ cy = RADAR_R + RADAR_PAD
+
+ disc = pygame.Surface((2 * RADAR_R, 2 * RADAR_R), pygame.SRCALPHA)
+ pygame.draw.circle(disc, (10, 10, 10, 160), (RADAR_R, RADAR_R), RADAR_R)
+ pygame.draw.circle(disc, (0, 255, 0, 90), (RADAR_R, RADAR_R), RADAR_R, 1)
+
+ for name, rng in beams.items():
+ angle = math.radians(int(name.split("_")[1]))
+ r = rng if rng >= 0 else LIDAR_MAX # -1 = no hit -> edge of disc
+ r = min(r, LIDAR_MAX)
+ near = 1.0 - r / LIDAR_MAX # 0 far, 1 point-blank (color only)
+ dist = r / LIDAR_MAX # 0 at car center, 1 at rim
+ # match the chase cam: car forward (+X) = screen up, car left (+Y) = screen left
+ px = RADAR_R - math.sin(angle) * dist * RADAR_R
+ py = RADAR_R - math.cos(angle) * dist * RADAR_R
+ color = (255, int(255 * (1 - near)), 0) # yellow far -> red close
+ pygame.draw.circle(disc, color, (int(px), int(py)), 5)
+
+ pygame.draw.circle(disc, (0, 200, 255), (RADAR_R, RADAR_R), 3) # the car
+ screen.blit(disc, (cx - RADAR_R, cy - RADAR_R))
+
+
+KEY_SZ = 34 # px per key cap
+KEY_GAP = 4
+KEY_PAD = 12 # px from the bottom-right corner
+
+
+def draw_wasd(screen, keys, font) -> None:
+ """Bottom-right WASD keycaps; a pressed key lights up green."""
+ # (label, key, col, row) — classic W over ASD layout
+ caps = [
+ ("W", pygame.K_w, 1, 0),
+ ("A", pygame.K_a, 0, 1),
+ ("S", pygame.K_s, 1, 1),
+ ("D", pygame.K_d, 2, 1),
+ ]
+ x0 = screen.get_width() - 3 * (KEY_SZ + KEY_GAP) - KEY_PAD
+ y0 = screen.get_height() - 2 * (KEY_SZ + KEY_GAP) - KEY_PAD
+ for label, key, col, row in caps:
+ rect = pygame.Rect(x0 + col * (KEY_SZ + KEY_GAP),
+ y0 + row * (KEY_SZ + KEY_GAP), KEY_SZ, KEY_SZ)
+ pressed = keys[key]
+ pygame.draw.rect(screen, (0, 200, 60) if pressed else (40, 40, 40), rect, border_radius=6)
+ pygame.draw.rect(screen, (0, 255, 90) if pressed else (90, 90, 90), rect, 2, border_radius=6)
+ text = font.render(label, True, (0, 0, 0) if pressed else (220, 220, 220))
+ screen.blit(text, text.get_rect(center=rect.center))
+
+
+def main(xml: str | None = None) -> None:
+ pygame.init()
+ screen = pygame.display.set_mode((900, 600))
+ pygame.display.set_caption("NeoRacer — pygame drive")
+ clock = pygame.time.Clock()
+ key_font = pygame.font.SysFont(None, 26)
+ timer_font = pygame.font.SysFont(None, 48)
+ elapsed = 0.0 # seconds shown on the timer
+ timing = False # T toggles this
+
+ joystick = None
+ if pygame.joystick.get_count() > 0:
+ joystick = pygame.joystick.Joystick(0)
+ print(f"using gamepad: {joystick.get_name()}")
+
+ sim = DriveSim(xml, width=900, height=600)
+ print(__doc__)
+
+ running = True
+ dt = 0.0
+ while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+ elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
+ running = False
+ elif event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
+ sim.reset()
+ elif event.type == pygame.KEYDOWN and event.key == pygame.K_t:
+ timing = not timing
+ elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
+ elapsed, timing = 0.0, False
+ elif event.type == pygame.JOYBUTTONDOWN and event.button == RESET_BUTTON:
+ sim.reset()
+
+ keys = pygame.key.get_pressed()
+ if joystick is not None:
+ throttle = (joystick.get_axis(ACCEL_AXIS) + 1) / 2 \
+ - (joystick.get_axis(BRAKE_AXIS) + 1) / 2
+ steer = -joystick.get_axis(STEER_AXIS) # stick right -> steer right (negative ctrl)
+ if abs(steer) < DEADZONE:
+ steer = 0.0
+ else:
+ throttle = keys[pygame.K_w] - keys[pygame.K_s]
+ steer = keys[pygame.K_a] - keys[pygame.K_d]
+
+ if keys[pygame.K_SPACE]:
+ sim.stop()
+ throttle, steer = 0.0, 0.0
+
+ sim.step(throttle, steer, dt)
+
+ frame = sim.render() # (h, w, 3); pygame surfaces are (w, h), hence the swap
+ screen.blit(pygame.surfarray.make_surface(frame.swapaxes(0, 1)), (0, 0))
+ if timing:
+ elapsed += dt
+ draw_lidar(screen, sim.lidar())
+ draw_wasd(screen, keys, key_font)
+ color = (0, 255, 90) if timing else (220, 220, 220)
+ label = timer_font.render(f"{int(elapsed // 60):02d}:{elapsed % 60:05.2f}", True, color)
+ screen.blit(label, (14, 12))
+ pygame.display.flip()
+
+ dt = clock.tick(60) / 1000
+
+ pygame.quit()
+
+
+if __name__ == "__main__":
+ main(sys.argv[1] if len(sys.argv) > 1 else None)
diff --git a/examples/pygame_drive/sim.py b/examples/pygame_drive/sim.py
new file mode 100644
index 0000000..2cadc20
--- /dev/null
+++ b/examples/pygame_drive/sim.py
@@ -0,0 +1,179 @@
+"""
+MuJoCo driving module for the pygame demo — import DriveSim, feed it
+normalized commands, blit what render() returns.
+
+ sim = DriveSim() # ramp course + car (or DriveSim("path/to/car.xml"))
+ sim.step(throttle, steer, dt) # both in [-1, 1], dt in wall-clock seconds
+ frame = sim.render() # RGB uint8 array (height, width, 3)
+
+Actuator mapping (from assets/neoracer.xml):
+ ctrl[0..3] = fl/fr/rl/rr wheel torque (N·m), positive = forward
+ ctrl[4] = steer_servo target angle (rad), positive = left, range ±0.4
+
+Run `python3 sim.py` for a windowless self-check.
+"""
+
+import math
+from pathlib import Path
+
+import mujoco
+import numpy as np
+
+_PROJECT_DIR = Path(__file__).resolve().parents[2]
+
+# ── tuning knobs ────────────────────────────────────────────────────────────────
+MAX_TORQUE = 0.15 # N·m per wheel at full throttle (0.35 launches like a rocket;
+ # below ~0.15 the car can't carry enough speed to clear the
+ # ramp jump even with a run-up)
+MOTOR_DAMPING = 0.0025 # N·m per rad/s of wheel spin — back-EMF of a DC motor.
+ # Sets the top speed (~MAX_TORQUE/MOTOR_DAMPING in rad/s,
+ # ≈1.9 m/s here after rolling friction) AND brakes the car
+ # when throttle is released, so letting go slows it down.
+ # Below ~1.5 m/s the car can't clear the ramp jump.
+ROLL_FRICTION = 0.02 # N·m of rolling resistance per wheel — back-EMF alone
+ # fades near zero speed and leaves the car creeping.
+MAX_STEER = 0.40 # rad — the model's Ackermann command limit
+THROTTLE_SLEW = 2.5 # /s — how fast the applied command chases the input.
+STEER_SLEW = 4.0 # /s Smooths digital WASD input; analog sticks barely notice.
+
+CAM_DISTANCE = 1.6 # m behind/above the car
+CAM_ELEVATION = -35.0 # deg looking down
+
+
+def _yaw_deg(quat) -> float:
+ """Car yaw (heading) in degrees from a MuJoCo [w,x,y,z] quaternion."""
+ w, x, y, z = quat[0], quat[1], quat[2], quat[3]
+ return math.degrees(math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)))
+
+
+def _slew(current: float, target: float, rate_dt: float) -> float:
+ """Move current toward target by at most rate_dt, without overshooting."""
+ if target > current:
+ return min(target, current + rate_dt)
+ return max(target, current - rate_dt)
+
+
+def _load_model(xml: str | None):
+ """
+ No arg -> compose the ramp course + car at runtime (same trick as
+ examples/manual_drive.py). A path -> load it as-is.
+ """
+ if xml:
+ return mujoco.MjModel.from_xml_path(xml)
+ scene = mujoco.MjSpec.from_file(str(_PROJECT_DIR / "assets" / "tracks" / "f1_track.xml"))
+ car = mujoco.MjSpec.from_file(str(_PROJECT_DIR / "assets" / "neoracer.xml"))
+ # attach the whole car spec (not just the car body): the ground plane lives
+ # in neoracer.xml's worldbody and must come along, or the scene has no floor
+ scene.attach(car, prefix="", frame=scene.worldbody.add_frame())
+ return scene.compile()
+
+
+class DriveSim:
+ """Owns the model, physics stepping, and the chase-camera renderer."""
+
+ def __init__(self, xml: str | None = None, width: int = 900, height: int = 600):
+ self.model = _load_model(xml)
+ self.data = mujoco.MjData(self.model)
+ self._car_id = self.model.body("car").id
+ # qvel address of each drive wheel's spin, via actuators 0..3 (the
+ # repo's ctrl contract), used for engine braking
+ self._wheel_dofs = [self.model.jnt_dofadr[self.model.actuator_trnid[i, 0]]
+ for i in range(4)]
+
+ # commands actually applied, chasing the caller's inputs (see step)
+ self._throttle = 0.0
+ self._steer = 0.0
+
+ # grow the offscreen framebuffer to the requested size before the
+ # renderer snapshots it (the MJCF default is only 640x480)
+ self.model.vis.global_.offwidth = max(width, self.model.vis.global_.offwidth)
+ self.model.vis.global_.offheight = max(height, self.model.vis.global_.offheight)
+ self._renderer = mujoco.Renderer(self.model, height=height, width=width)
+
+ self._cam = mujoco.MjvCamera()
+ self._cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING
+ self._cam.trackbodyid = self._car_id
+ self._cam.distance = CAM_DISTANCE
+ self._cam.elevation = CAM_ELEVATION
+
+ def step(self, throttle: float, steer: float, dt: float) -> None:
+ """Chase the normalized [-1, 1] inputs, then step physics dt seconds."""
+ throttle = max(-1.0, min(1.0, throttle))
+ steer = max(-1.0, min(1.0, steer))
+ dt = min(dt, 0.1) # a hitched frame shouldn't trigger a physics catch-up spiral
+
+ self._steer = _slew(self._steer, steer, STEER_SLEW * dt)
+ self.data.ctrl[4] = self._steer * MAX_STEER
+
+ # DC-motor model: commanded torque minus back-EMF drag. At full throttle
+ # the two balance at the top speed; at zero throttle the drag term is
+ # engine braking, so the car slows to a stop instead of coasting forever.
+ self._throttle = _slew(self._throttle, throttle, THROTTLE_SLEW * dt)
+ spin = self.data.qvel[self._wheel_dofs]
+ torque = self._throttle * MAX_TORQUE - MOTOR_DAMPING * spin
+ torque -= np.clip(spin * 0.05, -ROLL_FRICTION, ROLL_FRICTION) # smoothed Coulomb
+ self.data.ctrl[0:4] = np.clip(torque, -MAX_TORQUE, MAX_TORQUE)
+
+ target = self.data.time + dt
+ while self.data.time < target:
+ mujoco.mj_step(self.model, self.data)
+
+ def lidar(self) -> dict[str, float]:
+ """Current range (m) for each of the 8 lidar beams; -1 = no hit."""
+ names = [f"lidar_{a:03d}" for a in range(0, 360, 45)]
+ return {n: float(self.data.sensor(n).data[0]) for n in names}
+
+ def stop(self) -> None:
+ """Hard stop: zero both commands immediately (no slew-down)."""
+ self._throttle = 0.0
+ self._steer = 0.0
+ self.data.ctrl[:] = 0.0
+
+ def reset(self) -> None:
+ """Put the car back at the start (un-flip), commands zeroed."""
+ mujoco.mj_resetData(self.model, self.data)
+ self.stop()
+
+ def render(self):
+ """RGB frame from a chase camera locked behind the car."""
+ self._cam.azimuth = _yaw_deg(self.data.xquat[self._car_id])
+ self._renderer.update_scene(self.data, camera=self._cam)
+ return self._renderer.render()
+
+
+def _speed(sim: "DriveSim") -> float:
+ return float(np.linalg.norm(sim.data.qvel[0:2])) # freejoint vx, vy
+
+
+def _selftest() -> None:
+ """Smallest runnable check: drives, tops out, brakes on release, renders."""
+ sim = DriveSim(str(_PROJECT_DIR / "assets" / "neoracer.xml")) # flat plane
+ x0 = sim.data.xpos[sim._car_id][0].copy()
+ for _ in range(150): # ~3 s of full throttle
+ sim.step(1.0, 0.0, 0.02)
+ assert sim._throttle == 1.0, sim._throttle
+ moved = sim.data.xpos[sim._car_id][0] - x0
+ assert moved > 1.0, f"car only moved {moved:.3f} m under full throttle"
+ # top speed must stay near the back-EMF balance point, whatever the knobs say
+ top_spin = MAX_TORQUE / MOTOR_DAMPING # rad/s where drive torque = drag
+ spin = float(np.mean(sim.data.qvel[sim._wheel_dofs]))
+ assert spin < 1.1 * top_spin, f"wheels spin {spin:.0f} rad/s, model caps at {top_spin:.0f}"
+ v_release = _speed(sim) # release -> drag must brake the car, not coast forever
+ for _ in range(200): # 4 s hands-off
+ sim.step(0.0, 0.0, 0.02)
+ assert _speed(sim) < 0.1 * v_release, \
+ f"only braked {v_release:.2f} -> {_speed(sim):.2f} m/s in 4 s"
+ for _ in range(150):
+ sim.step(0.0, 1.0, 0.02)
+ assert sim._steer == 1.0 and sim.data.ctrl[4] == MAX_STEER
+ sim.stop()
+ assert sim._throttle == 0.0 and (sim.data.ctrl == 0).all()
+ course = DriveSim() # ramp-course composition compiles and renders
+ course.step(1.0, 0.0, 0.02)
+ h, w, c = course.render().shape
+ assert (h, w, c) == (600, 900, 3), (h, w, c)
+ print("selftest OK")
+
+
+if __name__ == "__main__":
+ _selftest()
diff --git a/users/amoghmpanhale/driving_demo/01_pygame.py b/users/amoghmpanhale/driving_demo/01_pygame.py
new file mode 100644
index 0000000..8a2505c
--- /dev/null
+++ b/users/amoghmpanhale/driving_demo/01_pygame.py
@@ -0,0 +1,43 @@
+# Example file showing a circle moving on screen
+import pygame
+
+# pygame setup
+pygame.init()
+screen = pygame.display.set_mode((1280, 720))
+clock = pygame.time.Clock()
+running = True
+dt = 0
+
+player_pos = pygame.Vector2(screen.get_width() / 2, screen.get_height() / 2)
+
+while running:
+ # poll for events
+ # pygame.QUIT event means the user clicked X to close your window
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # fill the screen with a color to wipe away anything from last frame
+ screen.fill("purple")
+
+ pygame.draw.circle(screen, "red", player_pos, 40)
+
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]:
+ player_pos.y -= 300 * dt
+ if keys[pygame.K_s]:
+ player_pos.y += 300 * dt
+ if keys[pygame.K_a]:
+ player_pos.x -= 300 * dt
+ if keys[pygame.K_d]:
+ player_pos.x += 300 * dt
+
+ # flip() the display to put your work on screen
+ pygame.display.flip()
+
+ # limits FPS to 60
+ # dt is delta time in seconds since last frame, used for framerate-
+ # independent physics.
+ dt = clock.tick(60) / 1000
+
+pygame.quit()
\ No newline at end of file
diff --git a/users/amoghmpanhale/driving_demo/02_press_and_hold.py b/users/amoghmpanhale/driving_demo/02_press_and_hold.py
new file mode 100644
index 0000000..a8464d0
--- /dev/null
+++ b/users/amoghmpanhale/driving_demo/02_press_and_hold.py
@@ -0,0 +1,26 @@
+import pygame
+
+pygame.init()
+
+screen = pygame.display.set_mode((1280, 720))
+clock = pygame.time.Clock()
+running = True
+
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]:
+ print("W key is being held down")
+ if keys[pygame.K_s]:
+ print("S key is being held down")
+ if keys[pygame.K_a]:
+ print("A key is being held down")
+ if keys[pygame.K_d]:
+ print("D key is being held down")
+
+ screen.fill((0, 0, 0))
+ pygame.display.flip()
+ clock.tick(60)
\ No newline at end of file
diff --git a/users/amoghmpanhale/osracer-description/launch/robot_description_tf.launch.py b/users/amoghmpanhale/osracer-description/launch/robot_description_tf.launch.py
index 5f4c487..0a8f356 100644
--- a/users/amoghmpanhale/osracer-description/launch/robot_description_tf.launch.py
+++ b/users/amoghmpanhale/osracer-description/launch/robot_description_tf.launch.py
@@ -1,6 +1,3 @@
-import os
-from launch.actions import DeclareLaunchArgument
-from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from launch import LaunchDescription
from launch.actions import OpaqueFunction