server.server
Minimal raylib-based UAV Trajectory Simulation with TCP connection to control the drone. Constructs a simple obstacle world (cubes on a grid).
Keybinds:
- Controls on cameras (world, topdown, FPV):
- W, A, S, D (both cameras) + mouse for rotation (just for world camera).
- Q, E, arrows, PgUp, PgDown (just for FPV)
- Misc:
- F1/F2/F3: Switch to world camera (3D overview) / top-dopwn camera (2D view) / FPV camera (UAV)
- ESC: Quit
1#!/usr/bin/env python3 2""" 3Minimal raylib-based UAV Trajectory Simulation with TCP connection to control the drone. 4Constructs a simple obstacle world (cubes on a grid). 5 6Keybinds: 7- Controls on cameras (world, topdown, FPV): 8 - W, A, S, D (both cameras) + mouse for rotation (just for world camera). 9 - Q, E, arrows, PgUp, PgDown (just for FPV) 10- Misc: 11 - F1/F2/F3: Switch to world camera (3D overview) / top-dopwn camera (2D view) / FPV camera (UAV) 12 - ESC: Quit 13""" 14from __future__ import annotations 15from dataclasses import dataclass, field 16from pathlib import Path 17from copy import deepcopy 18from queue import Empty 19from typing import Any 20from collections import deque 21from functools import partial 22from argparse import ArgumentParser, Namespace 23import traceback 24import json 25import sys 26import time 27 28from overrides import overrides 29import numpy as np 30import raylib as rl 31from microecs import World, EntityId, Component 32from microspec import Protocol 33 34from robosim.camera import Camera 35from robosim.network import ConnectionManager, ObjToSimChannel 36from robosim.utils import (Point3D, Clock, FPVData, logger, get_project_root, get_closest_square, 37 pose_from_position_target_up, pose_from_trans_euler as pte, make_arr, FixedSizeDict, 38 rl_get_device_and_renderer) 39from robosim.constants import (HZ, SCREEN_HEIGHT, SCREEN_WIDTH, DT, CONNECT_CMD, 40 MAX_SUBTICKS_PER_RENDER_TICK, SOCKET_TIMEOUT_S, DEFAULT_BACKGROUND, TICK_STATS_LEN, 41 LVL2_DRAG_COEFF, LVL2_MAX_ACCELERATIONS) 42from robosim.traits import Restorable, Serializable, Drawable 43from robosim.plugins_manager import PluginsManager 44from robosim.physics import make_collision_cell_size 45from robosim.entities import make_cube, make_mesh, make_robot 46from robosim.components import (ROBOSIM_COMPONENTS, ColliderKinds, HasModel, HasPose, HasFPV, HasChannel, 47 HasMotionInput, HasPhysicsLevel1, HasPhysicsLevel2, HasCollision) 48from robosim.systems import FPVCameraSystem, PhysicsSystem 49 50np.set_printoptions(precision=3, linewidth=120) 51rl.SetTraceLogLevel(rl.LOG_WARNING) 52 53sys.path.append(str(get_project_root() / "server/plugins")) 54from manual_move_plugin import ManualMovePlugin # pylint: disable=wrong-import-order, import-error, wrong-import-position 55from race_plugin import RacePlugin # pylint: disable=wrong-import-order, import-error, wrong-import-position 56from trajectory_mission_plugin import TrajectoryMissionPlugin # pylint: disable=wrong-import-order, import-error, wrong-import-position 57 58# global settings and types 59 60RESOURCES_PATH = get_project_root() / "resources" 61INIT_CONFIG: dict | None = None 62UAV_TRACES_MAX_LEN = 1000 63 64PluginsManager.REGISTERED_PLUGINS = { # plugin registration 65 "manual_move": ManualMovePlugin, 66 "trajectory_mission": TrajectoryMissionPlugin, 67 "race": RacePlugin, 68} 69 70# simulator-specific components (e.g. not generic which whould live in robosim/components). 71# generic ones should have the 'Has' prefix (e.g. HasPose), while non-generic ones usually don't (e.g. GroundFloor) 72 73class GroundFloor(Component): 74 """component used to tag the floor so we don't render it in collision mode""" 75 76ALL_COMPONENTS = [*ROBOSIM_COMPONENTS, GroundFloor] 77 78# serialization / build entities 79 80def world_to_dict(world: World) -> dict[str, Any]: 81 """Serialize the world. Goes through all the entities and their components and converts the serializables to dict""" 82 res = {"entities": [], "components": world.component_names, "extra_metadata": world.extra_metadata} 83 for eid in world.live_entities.keys(): 84 res["entities"].append({"entity_id": eid, **world.get_entity(eid).to_dict(serialization_field="serializable")}) 85 return res 86 87def world_from_dict(data: dict[str, Any]) -> World: 88 """Creates a world from a serialized representation e.g. from world_to_dict()""" 89 components = [{c.__name__: c for c in ALL_COMPONENTS}[c] for c in data["components"]] 90 world = World(components=components, extra_metadata=data["extra_metadata"]) 91 92 for entity in data["entities"]: 93 entity_data = entity["data"] 94 entity_components = [{c.__name__: c for c in ALL_COMPONENTS}[c] for c in entity["components"]] 95 assert "_type" in entity["data"], f"_type not in serialized data for {entity} (id: {entity['entity_id']}" 96 97 if entity_data["_type"]["type"] == "cube": 98 texture_path = get_project_root() / tp if (tp := entity_data["_args"]["texture_path"]) is not None else None 99 eid = make_cube(world, pose=np.float32(entity_data["pose"]), scale=entity_data["_args"]["scale"], 100 size=np.float32(entity_data["_args"]["size"]), texture_path=texture_path) 101 if GroundFloor in entity_components: 102 world.update() 103 world.get_entity(eid).add_component(GroundFloor) 104 105 elif entity_data["_type"]["type"] == "mesh": 106 texture_path = get_project_root() / tp if (tp := entity_data["_args"]["texture_path"]) is not None else None 107 collidable = HasCollision in entity_components 108 make_mesh(world, pose=np.float32(entity_data["pose"]), scale=entity_data["_args"]["scale"], 109 model_path=get_project_root() / entity_data["_args"]["model_path"], texture_path=texture_path, 110 collidable=collidable) 111 112 elif entity_data["_type"]["type"] == "robot": 113 kwargs = {} 114 if HasPhysicsLevel1 in entity_components: 115 kwargs = { 116 "physics_type": "level1", 117 "max_velocities": np.float32(entity_data["max_velocities"]), 118 "velocity": np.float32(entity_data["velocity"]), 119 "acceleration": np.float32(entity_data["acceleration"]), 120 } 121 elif HasPhysicsLevel2 in entity_components: 122 kwargs = { 123 "physics_type": "level2", 124 "drag_coefficient": np.float32(entity_data["drag_coefficient"]), 125 "max_accelerations": np.float32(entity_data["max_accelerations"]), 126 "velocity": np.float32(entity_data["velocity"]), 127 "acceleration": np.float32(entity_data["acceleration"]), 128 } 129 130 make_robot(world, pose=np.float32(entity_data["pose"]), 131 scale=entity_data["_args"]["scale"], 132 model_path=get_project_root() / entity_data["_args"]["model_path"], 133 **kwargs) 134 else: 135 raise NotImplementedError(entity_data["_type"]["type"]) 136 # TODO: we should assert here that all the newly added entities have the same components as these that were in the 137 # previous state. This means we need to do world.update() and validate each added component. 138 return world 139 140# map stuff 141 142def build_default_scene_objects() -> World: 143 """builds the default basic scene objects for the simulator: n robots + a few obstacles""" 144 # NOTE: if plugins add their own components, ideally they should be in a separate plugin-owned world! Not enforced.. 145 world = World(components=ALL_COMPONENTS, extra_metadata=["serializable", "comment"]) 146 147 crate_png = RESOURCES_PATH / "textures/crate.png" 148 grass_png = RESOURCES_PATH / "textures/grass.png" 149 150 # Ground plane 151 eid = make_cube(world, pte(make_arr(0, 0, 0, 0, 0, 0)), make_arr(20, 0.1, 20), scale=1, texture_path=grass_png) 152 world.update() 153 world.get_entity(eid).add_component(GroundFloor) 154 # Some scattered cubes at ground level 155 make_cube(world, pte(make_arr(5.0, 0.5, 5.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 156 make_cube(world, pte(make_arr(5.0, 0.5, -3.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 157 make_cube(world, pte(make_arr(-4.0, 0.5, 2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 158 make_cube(world, pte(make_arr(-6.0, 0.5, -5.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 159 make_cube(world, pte(make_arr(8.0, 0.5, 0.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 160 # A small tower (stacked cubes) 161 make_cube(world, pte(make_arr(-2.0, 0.5, -2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 162 make_cube(world, pte(make_arr(-2.0, 1.5, -2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 163 make_cube(world, pte(make_arr(-2.0, 2.5, -2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 164 # Floating obstacles (for UAV to navigate around) 165 make_cube(world, pte(make_arr(3.0, 3.0, 3.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 166 make_cube(world, pte(make_arr(-3.0, 4.0, 0.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 167 make_cube(world, pte(make_arr(0.0, 5.0, -4.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 168 # Directly in UAV's POV at start (UAV at y=6, looking +Z) 169 make_cube(world, pte(make_arr(0.0, 5.5, 8.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 170 # House model 171 make_mesh(world, RESOURCES_PATH / "models/house/house.obj", pte(make_arr(5, 2, 0, 0, 0, 0)), 172 scale=0.1, collidable=False) 173 return world 174 175def build_robots(world: World, n_robots: int, robot_models: list[str]): 176 """build the robots in the scene""" 177 _, c = get_closest_square(n_robots) 178 model_paths = [RESOURCES_PATH / "models" / x / f"{x}.obj" for x in robot_models] 179 scales = [0.8] * len(model_paths) 180 181 # physics_type = "level1" 182 # kwargs = { 183 # "max_velocities": np.float32(LVL1_MAX_VELOCITIES), 184 # } 185 physics_type = "level2" 186 kwargs = { 187 "max_accelerations": np.float32(LVL2_MAX_ACCELERATIONS), 188 "drag_coefficient": np.float32((LVL2_DRAG_COEFF, )), 189 } 190 191 for i in range(n_robots): 192 pose = pose_from_position_target_up(position=make_arr(i % c, 6, i // c), 193 target=make_arr(i % c, 6, i // c + 1), up=make_arr(0, 1, 0)) 194 model_path = model_paths[i % len(model_paths)] 195 scale = scales[i % len(model_paths)] 196 make_robot(world, model_path, pose, scale, physics_type, **kwargs) 197 198# Simulator singleton and state object 199 200@dataclass(kw_only=True) 201class SimState(Serializable): 202 """The state of the scene including the cameras, robot and so on""" 203 world_camera: Camera | None = None 204 topdown_camera: Camera | None = None 205 active_camera_type: str = "world" # Which camera is active based on it's type 206 fpv_ix: int = 0 207 wireframe_mode: bool = False 208 collision_render_mode: bool = False 209 collision_cell_size: Point3D = field(default_factory=lambda: make_arr(30, 30, 30)) 210 display_uav_trace: bool = False 211 uav_traces: dict[EntityId, FixedSizeDict[tuple[float, float, float], bool]] = field(default_factory=dict) 212 213 def __post_init__(self): 214 if self.world_camera is None: 215 self.world_camera = Camera(make_arr(15, 12, 15), make_arr(0, 2, 0), make_arr(0, 1, 0), 216 fovy=45, projection=rl.CAMERA_PERSPECTIVE) 217 if self.topdown_camera is None: 218 self.topdown_camera = Camera(make_arr(0, 30, 0), make_arr(0, 0, 0), make_arr(0, 0, -1), 219 fovy=20, projection=rl.CAMERA_ORTHOGRAPHIC) 220 221 @property 222 def active_camera_label(self) -> str: 223 """returns a string representation of the active camera for HUD""" 224 match self.active_camera_type: 225 case "world": return "WORLD CAMERA (F1)" 226 case "topdown": return "TOP-DOWN CAMERA (F2)" 227 case "fpv": return "FPV CAMERA (F3)" 228 case _: raise ValueError(self.active_camera_type) 229 230 @overrides 231 def to_dict(self) -> dict: 232 return { 233 "world_camera": self.world_camera.to_dict(), 234 "topdown_camera": self.topdown_camera.to_dict(), 235 "active_camera_type": self.active_camera_type, 236 "fpv_ix": self.fpv_ix, 237 "wireframe_mode": self.wireframe_mode, 238 "collision_render_mode": self.collision_render_mode, 239 "collision_cell_size": self.collision_cell_size.tolist(), 240 "display_uav_trace": self.display_uav_trace, 241 "uav_traces": FixedSizeDict({k: list(v.keys()) for k, v in self.uav_traces.items()}, 242 maxlen=UAV_TRACES_MAX_LEN), 243 } 244 245 @staticmethod 246 @overrides 247 def from_dict(state: dict) -> SimState: 248 return SimState( 249 world_camera=Camera.from_dict(state["world_camera"]), 250 topdown_camera=Camera.from_dict(state["topdown_camera"]), 251 active_camera_type=state["active_camera_type"], 252 fpv_ix=state["fpv_ix"], 253 wireframe_mode=state["wireframe_mode"], 254 collision_render_mode=state["collision_render_mode"], 255 collision_cell_size=np.float32(state["collision_cell_size"]), 256 display_uav_trace=state["display_uav_trace"], 257 uav_traces={int(eid): FixedSizeDict({tuple(pt): True for pt in points}, maxlen=UAV_TRACES_MAX_LEN) 258 for eid, points in state["uav_traces"].items()}, 259 ) 260 261class Simulator(Restorable): 262 """The simulator object. Contains the scene (objects/robots), state and events. Wires to/from_dict as well.""" 263 def __init__(self, world: World, state: SimState, connection_manager: ConnectionManager, 264 plugins: PluginsManager, protocol: Protocol): 265 self.world = world 266 self.state = state 267 self.connection_manager = connection_manager 268 self.plugins = plugins 269 self.protocol = protocol 270 271 self.physics_ticks_stats = deque(maxlen=TICK_STATS_LEN) 272 self.render_ticks_stats = deque(maxlen=TICK_STATS_LEN) 273 274 self.world.update() 275 self.robot_eids = self.world.query(HasFPV).entity_ids.copy() 276 self.channel_id_to_robot_eid: dict[int, int] = {} 277 for i, (channel, eid) in enumerate(zip(self.channels, self.robot_eids)): # network channels are not part of ECS 278 self.world.get_entity(eid).add_component(HasChannel, channel=np.array([channel], "object")) 279 self.channel_id_to_robot_eid[i] = eid 280 281 self.world.update() 282 283 @property 284 def channels(self) -> list[ObjToSimChannel]: 285 """The communication channels that this simulator has""" 286 return self.connection_manager.channels 287 288 def render_main_camera(self, extra_drawables: list[Drawable] | None): 289 """renders the main camera (the raylib UI) of the simulator""" 290 rl.BeginDrawing() 291 rl.ClearBackground(DEFAULT_BACKGROUND) 292 293 rl.BeginMode3D(self.active_camera.camera[0]) 294 295 if self.state.wireframe_mode: 296 rl.rlEnableWireMode() 297 298 if self.state.collision_render_mode: 299 self._render_collision_mode(extra_drawables) 300 else: 301 entity_id = self.robot_eids[self.state.fpv_ix].item() if self.state.active_camera_type == "fpv" else None 302 self._render(entity_id=entity_id, extra_drawables=extra_drawables) 303 304 if self.state.display_uav_trace: 305 for traces in self.state.uav_traces.values(): 306 for trace in traces.keys(): 307 rl.DrawSphere(trace, 0.05, rl.GREEN) 308 309 rl.EndMode3D() 310 311 if self.state.wireframe_mode: 312 rl.rlDisableWireMode() 313 314 n_robots = len(self.world.query(HasFPV)) 315 msg = f"{self.state.active_camera_label}\nObjects: {len(self.world)} (robots: {n_robots})" 316 rl.DrawText(msg.encode(), 10, 10, 20, rl.DARKGRAY) 317 rl.DrawFPS(rl.GetScreenWidth() - 80, 10) 318 rl.EndDrawing() 319 320 def render_fpv_cameras(self, extra_drawables: list[Drawable] | None): 321 """Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.""" 322 qr = self.world.query(HasChannel, HasFPV) 323 for i, (channel, fpv_camera, fpv_texture, fpv_data) in enumerate(zip(qr.channel, qr.fpv_camera, 324 qr.fpv_texture, qr.fpv_data)): 325 if channel.item().client is None: 326 continue 327 fpv_texture: "rl.RenderTexture" = fpv_texture.item() 328 fpv_data: FPVData = fpv_data.item() 329 camera: Camera = fpv_camera.item() 330 331 rl.BeginTextureMode(fpv_texture) 332 rl.ClearBackground(DEFAULT_BACKGROUND) 333 rl.BeginMode3D(camera.camera[0]) 334 self._render(entity_id=qr.entity_ids[i].item(), extra_drawables=extra_drawables) 335 rl.EndMode3D() 336 rl.EndTextureMode() 337 338 fpv_img = rl.LoadImageFromTexture(fpv_texture.texture) 339 fpv_img_ptr = rl.ffi.new("Image *", fpv_img) 340 rl.ImageFlipVertical(fpv_img_ptr) 341 with fpv_data.lock: 342 fpv_data.frame = bytes(rl.ffi.buffer(fpv_img_ptr.data, len(fpv_data.frame))) 343 fpv_data.frame_id += 1 344 fpv_data._frame_compressed = None # pylint: disable=protected-access 345 rl.UnloadImage(fpv_img_ptr[0]) 346 347 @property 348 def active_camera(self) -> Camera: 349 """The current active camera out of the 3: world, uav or topdown""" 350 match self.state.active_camera_type: 351 case "world": return self.state.world_camera 352 case "topdown": return self.state.topdown_camera 353 case "fpv": return self.world.get_entity( 354 self.world.query(HasFPV).entity_ids[self.state.fpv_ix]).fpv_camera[0] 355 case _: raise ValueError(self.state.active_camera_type) 356 357 @overrides 358 def to_dict(self) -> dict: 359 return { 360 "world": world_to_dict(self.world), 361 "state": self.state.to_dict(), 362 "plugins": self.plugins.to_dict(), 363 } 364 365 @overrides 366 def load_state_dict(self, state: dict): 367 state = deepcopy(state) # this is mostly for INIT_STATE as the code below mutates it while loading 368 369 self._load_world_from_dict(state["world"]) 370 self.state = SimState.from_dict(state["state"]) 371 372 old_names = set(self.plugins.plugin_names) 373 for dropped in old_names - state["plugins"].keys(): 374 logger.warning(f"Plugin '{dropped}' was live but absent from loaded state. Dropping.") 375 self.plugins = PluginsManager.from_dict(state["plugins"]) 376 377 def _load_world_from_dict(self, world_state: dict): 378 world = world_from_dict(world_state) 379 world.update() 380 381 self.robot_eids = world.query(HasFPV).entity_ids.copy() 382 assert (A:=len(self.robot_eids)) == (B:=len(self.channels)), f"Loaded robots: {A} doesn't match existing: {B}" 383 for i, (channel, eid) in enumerate(zip(self.channels, self.robot_eids)): # network channels must be updated too 384 world.get_entity(eid).add_component(HasChannel, channel=np.array([channel], "object")) 385 self.channel_id_to_robot_eid[i] = eid 386 world.update() 387 388 logger.info(f"Loaded world from state: {world}") 389 self.world = world 390 391 def _render(self, entity_id: int | None, extra_drawables: list[Drawable] | None): #noqa 392 """Renders the scene and all its objects + robots. Called from within a rl.BeginMode3D() block.""" 393 qr = self.world.query(HasModel, HasPose) 394 for i, (model, pose, scale) in enumerate(zip(qr.model, qr.pose, qr.scale)): 395 if entity_id is not None and entity_id == qr.entity_ids[i]: # if in FPV mode then don't draw yourself 396 continue 397 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 398 399 for drawable in (extra_drawables or []): # e.g. plugins 400 drawable.draw(self.world, entity_id) 401 402 def _render_collision_mode(self, extra_drawables: list[Drawable] | None): 403 """render the scene in 'collision mode' which means we don't render the meshes of collidables, but the shapes""" 404 qr = self.world.query(HasModel, HasPose, exclude=[HasCollision]) 405 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 406 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 407 408 qr = self.world.query(HasModel, HasPose, HasCollision, exclude=[GroundFloor]) 409 collider_size = qr.collider_bbox[:, 1] - qr.collider_bbox[:, 0] 410 for pose, kind, radius, size, colliding in zip(qr.pose, qr.collider_kind, qr.collider_radii, 411 collider_size, qr.is_colliding): 412 color = rl.RED if colliding else rl.GREEN 413 if kind == ColliderKinds.SPHERE: 414 rl.DrawSphere(pose[0:3, 3].tolist(), radius.item(), color) 415 elif kind == ColliderKinds.AABB: 416 rl.DrawCube(pose[0:3, 3].tolist(), *size.tolist(), color) 417 else: 418 raise NotImplementedError(kind) 419 420 qr = self.world.query(HasModel, HasPose, GroundFloor) 421 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 422 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 423 424 for drawable in (extra_drawables or []): # e.g. plugins 425 drawable.draw(self.world, None) 426 427 def __repr__(self): 428 return f"[Simulator]\n{self.world}\n{self.plugins}\n{self.connection_manager}\n{self.protocol}" 429 430# I/O stuff: network/keyboard handlers 431 432def keyboard_handler(sim: Simulator): 433 """handle keyboard input for the main UI. We create a message in the same protocol of robosim-ncat/global hanlder""" 434 msg = None 435 436 # camera management 437 if rl.IsKeyPressed(rl.KEY_F1): 438 msg = {"cmd": "sim_set_camera", "type": "world", "id": 0} # dummy ids, needed for protocol 439 if rl.IsKeyPressed(rl.KEY_F2): 440 msg = {"cmd": "sim_set_camera", "type": "topdown", "id": 0} # dummy ids, needed for protocol 441 if rl.IsKeyPressed(rl.KEY_F3): # we still keep the 'fpv' camera here but cannot control it. 442 if sim.state.active_camera_type == "fpv": 443 sim.state.fpv_ix = (sim.state.fpv_ix + 1) % len(sim.world.query(HasFPV)) 444 msg = {"cmd": "sim_set_camera", "type": "fpv", "id": sim.state.fpv_ix} 445 446 # sim state management 447 if rl.IsKeyPressed(rl.KEY_F5): # save the state of the scene on the server side 448 msg = {"cmd": "sim_save_state"} 449 if rl.IsKeyPressed(rl.KEY_F6): # load the state of the scene on the server side 450 try: 451 with open(RESOURCES_PATH / "state.json", "r") as fp: 452 state = json.load(fp) 453 msg = {"cmd": "sim_load_state", "state": state} 454 except Exception as e: 455 logger.error(str(e)) 456 if rl.IsKeyPressed(rl.KEY_R): 457 msg = {"cmd": "sim_load_state", "state": INIT_CONFIG} 458 459 # render management 460 if rl.IsKeyPressed(rl.KEY_I): 461 msg = {"cmd": "sim_set_wireframe_mode", "value": not sim.state.wireframe_mode} 462 if rl.IsKeyPressed(rl.KEY_O): 463 msg = {"cmd": "sim_set_collision_render_mode", "value": not sim.state.collision_render_mode} 464 465 # move robot via keyboard 466 if sim.state.active_camera_type == "fpv": 467 entity_id = sim.robot_eids[sim.state.fpv_ix] 468 control_input = np.float32([ 469 rl.IsKeyDown(rl.KEY_A) - rl.IsKeyDown(rl.KEY_D), # left/right (x) 470 rl.IsKeyDown(rl.KEY_PAGE_UP) - rl.IsKeyDown(rl.KEY_PAGE_DOWN), # up/down (y) 471 rl.IsKeyDown(rl.KEY_W) - rl.IsKeyDown(rl.KEY_S), # forward/backward (z) 472 rl.IsKeyDown(rl.KEY_DOWN) - rl.IsKeyDown(rl.KEY_UP), # pitch 473 rl.IsKeyDown(rl.KEY_Q) - rl.IsKeyDown(rl.KEY_E), # yaw 474 rl.IsKeyDown(rl.KEY_RIGHT) - rl.IsKeyDown(rl.KEY_LEFT), # roll 475 ]) 476 if (control_input != 0).any(): 477 msg = {"cmd": "entity_move", "entity_id": entity_id.item(), "control_input": control_input} 478 479 # misc / other commands 480 if rl.IsKeyPressed(rl.KEY_F9): 481 msg = {"cmd": "sim_set_collision_cell_size", "value": np.maximum(sim.state.collision_cell_size - 0.1, 0.1)} 482 if rl.IsKeyPressed(rl.KEY_F10): 483 msg = {"cmd": "sim_set_collision_cell_size", "value": np.minimum(sim.state.collision_cell_size + 0.1, 30)} 484 if rl.IsKeyPressed(rl.KEY_T): 485 msg = {"cmd": "sim_display_uav_trace", "value": not sim.state.display_uav_trace} 486 487 if msg is not None: 488 res = _handle_msg(sim, msg) 489 if "error" in res: 490 logger.error(res["error"]) 491 else: 492 if msg["cmd"] != "entity_move": 493 logger.debug(res["status"]) 494 495def network_handler(msg: dict, global_channel: ObjToSimChannel, connected_channel: ObjToSimChannel | None, 496 sim: Simulator) -> dict | None: 497 """Ran in TCP thread. Fast path for one-off messages. If not fast -> sends to the right channel (global/conn). 498 Returns None if the message requires to be authenticated ('connect'), but the client isn't (channel is None)""" 499 assert "cmd" in msg, msg # should be handlded in connection manager already 500 501 all_cmds = [*list(sim.protocol.endpoints), *sim.plugins.all_endpoints] 502 if (cmd := msg["cmd"]) not in all_cmds: 503 return {"error": f"Unknown command: '{cmd}'", "supported_commands": all_cmds} 504 505 # these will go in the plugins manager and get handled by the right plugin 506 if cmd in sim.plugins.all_endpoints: 507 # If the client is not connected (e.g. controls a robot), then the handler returns and client gets disconnected 508 # TODO: it may be the case that some plugin messages should work even non connected (e.g. mission_get_state) 509 if connected_channel is None: 510 return None 511 512 with connected_channel.lock: 513 connected_channel.tcp2sim.put(msg) 514 return connected_channel.sim2tcp.get(timeout=SOCKET_TIMEOUT_S) 515 516 # guaranteed to be an admin/global command here 517 518 if (err := sim.protocol.validate_endpoint(endpoint=cmd, data=msg)) is not None: 519 logger.error(err) 520 return {"error": err.error} 521 522 if cmd == "help": 523 return {"supported_commands": all_cmds} 524 if cmd == "sim_get_info": 525 return {"control_loop_rate_hz": HZ, 526 "physics_tick_s": {"window": len(pts := sim.physics_ticks_stats), "p50": np.median(pts).item()}, 527 "render_tick_s": {"window": len(rts := sim.render_ticks_stats), "p50": np.median(rts).item()},} 528 if cmd == "sim_get_state": 529 return sim.to_dict() 530 531 if cmd in ("robot_get_state", "robot_get_state_with_camera"): 532 channel = sim.channels[robot_ix := msg["robot_ix"]] 533 robot_eid = sim.channel_id_to_robot_eid[robot_ix] 534 robot_entity = sim.world.get_entity(robot_eid) 535 robot_state = robot_entity.to_dict(serialization_field="serializable") 536 robot_state["connected"] = channel.client is not None 537 fpv_data: FPVData = robot_entity.fpv_data.item() 538 539 if cmd == "robot_get_state": 540 return {"robot": robot_state} 541 542 if robot_state["connected"] is False: 543 return {"error": f"Robot '{robot_ix}' is not connected. Cannot get FPV data"} 544 545 with fpv_data.lock: 546 res = {"robot": robot_state, "fpv_compressed": (fc := fpv_data.frame_compressed), 547 "fpv_shape": (fs := fpv_data.frame_shape), "fpv_frame_id": fpv_data.frame_id} 548 logger.log_every_s(f"FPV: {len(fc)} bytes (cr {len(fc)/(np.prod(fs)*3/4)*100:.2f}%)", "DEBUG", True) # RGB 549 return res 550 551 # These need to run on the main thread, so we use a special channel 552 with global_channel.lock: 553 global_channel.tcp2sim.put(msg) 554 return global_channel.sim2tcp.get(timeout=SOCKET_TIMEOUT_S) 555 556def _handle_load_state(sim: Simulator, new_state: dict) -> dict: 557 try: 558 for plugin in sim.plugins: 559 if isinstance(plugin, TrajectoryMissionPlugin) and plugin.state.phase == "running": 560 return {"error": "cannot reset while mission is running"} 561 sim.load_state_dict(new_state) 562 return {"status": "Loaded state"} 563 except Exception as e: 564 logger.error(f"Error: {e}\n{''.join(traceback.format_exception(e))}") 565 return {"error": str(e)} 566 567def _handle_msg(sim: Simulator, msg: dict) -> dict: 568 if not isinstance(msg, dict) or "cmd" not in msg: 569 return {"error": f"Wrong message format: {msg}"} 570 cmd = msg["cmd"] 571 572 if (err := sim.protocol.validate_endpoint(endpoint=cmd, data=msg)) is not None: 573 logger.error(err) 574 return {"error": err.error} 575 576 if cmd == "sim_set_camera": 577 if msg["type"] == "fpv": 578 sim.state.fpv_ix = msg["id"] 579 sim.state.active_camera_type = msg["type"] 580 return {"status": f"changed camera to '{sim.state.active_camera_type}'"} 581 582 elif cmd == "map_load": 583 raise NotImplementedError 584 585 # sim state management 586 587 elif cmd == "sim_reset": 588 # TODO: validate loaded state 589 return _handle_load_state(sim, new_state=INIT_CONFIG) 590 591 elif cmd == "sim_load_state": 592 # TODO: validate loaded state 593 return _handle_load_state(sim, new_state=msg["state"]) 594 595 elif cmd == "sim_save_state": 596 try: 597 with open(RESOURCES_PATH / "state.json", "w") as fp: 598 json.dump(sim.to_dict(), fp, indent=4) 599 except Exception as e: 600 return {"error": str(e)} 601 return {"status": "Stored the state of the simulator to 'resources/state.json'"} 602 603 # render management 604 605 elif cmd == "sim_set_wireframe_mode": 606 sim.state.wireframe_mode = msg["value"] 607 return {"status": f"Set wireframe mode to: {sim.state.wireframe_mode}"} 608 609 elif cmd == "sim_set_collision_render_mode": 610 sim.state.collision_render_mode = msg["value"] 611 return {"status": f"Set collision render mode to: {sim.state.collision_render_mode}"} 612 613 # entity control management (e.g. move entities or in the future add/remove components or set values) 614 615 elif cmd == "entity_move": 616 if (entity_id := msg["entity_id"]) not in sim.world.live_entities: 617 return {"error": f"Entity with id: {entity_id} not in the world"} 618 619 entity = sim.world.get_entity(entity_id) 620 if not entity.has_component(HasMotionInput): 621 return {"error": f"Entity with id: {entity_id} does not have 'HasMotionInput' component"} 622 try: 623 entity.motion_input[:] = msg["control_input"] 624 except Exception as e: 625 return {"error": str(e)} 626 return {"status": f"Entity id: {entity_id}. Applied control_input: {msg['control_input']}"} 627 628 # misc / other commands 629 630 elif cmd == "sim_set_collision_cell_size": 631 sim.state.collision_cell_size = msg["value"] 632 return {"status": f"Set collision cell size to: {msg['value']}"} 633 634 elif cmd == "sim_display_uav_trace": 635 sim.state.uav_traces.clear() 636 sim.state.display_uav_trace = msg["value"] 637 return {"status": f"Display UAV trace set to to: {msg['value']}"} 638 639 raise ValueError(f"Shouldn't reach here: {msg}") 640 641def global_handler(sim: Simulator): 642 """Ran in main thread. Handles the admin commands that do not run in the 'fast path' & need main thread (opengl)""" 643 try: 644 msg = sim.connection_manager.global_channel.tcp2sim.get_nowait() 645 except Empty: 646 return 647 sim.connection_manager.global_channel.sim2tcp.put(_handle_msg(sim, msg)) 648 649# main and cli args 650 651def main(args: Namespace): 652 """main fn""" 653 global INIT_CONFIG # pylint: disable=global-statement 654 rl.SetConfigFlags(rl.FLAG_WINDOW_HIDDEN | rl.FLAG_WINDOW_UNDECORATED if args.headless else rl.FLAG_WINDOW_RESIZABLE) 655 rl.InitWindow(SCREEN_WIDTH, SCREEN_HEIGHT, b"UAV Trajectory Simulation") 656 rl.SetWindowPosition(20, 200) 657 rl.SetTargetFPS(0) 658 rl.DisableEventWaiting() 659 rl.rlEnableBackfaceCulling() 660 661 if args.map_path is None: 662 world = build_default_scene_objects() 663 else: 664 with open(args.map_path, "r") as fp: 665 world = world_from_dict(json.load(fp)["world"]) 666 build_robots(world, args.n_robots, args.robot_models) 667 668 state = SimState(collision_cell_size=make_collision_cell_size(world)) 669 conn_manager = ConnectionManager("0.0.0.0", args.port, n_channels=args.n_robots, connect_command=CONNECT_CMD) 670 671 plugins = PluginsManager(plugin_names=args.plugins) 672 with open(args.protocol_path, "r") as fp: 673 protocol = Protocol.from_dict(json.load(fp), max_robot_ix=args.n_robots - 1) 674 sim = Simulator(world=world, state=state, connection_manager=conn_manager, plugins=plugins, protocol=protocol) 675 logger.info("Created default map" if args.map_path is None else f"Constructed map from '{args.map_path}'") 676 logger.info(f"Created {len(world.query(HasModel, exclude=[HasFPV]))} objects and {len(world.query(HasFPV))} robots") 677 logger.info(f"Created simulator object:\n{sim}") 678 logger.info(f"Raylib rendering on: {' / '.join(rl_get_device_and_renderer())}") 679 INIT_CONFIG = sim.to_dict() 680 conn_manager.start(partial(network_handler, sim=sim)) 681 682 fpv_system = FPVCameraSystem() 683 physics_system = PhysicsSystem() 684 685 clock = Clock(dt=DT, max_ticks=MAX_SUBTICKS_PER_RENDER_TICK) 686 while True: 687 sim.world.update() 688 clock.wait_and_tick() 689 690 # I/O systems: read messages from the keyboard as well as all robots (via plugins/channels) 691 if rl.IsKeyPressed(rl.KEY_ESCAPE) or not conn_manager.tcp_thread.is_alive(): 692 break 693 sim.world.query(HasMotionInput).motion_input[:] = 0 # reset all the inputs for the physics system 694 keyboard_handler(sim) 695 global_handler(sim) 696 sim.plugins.io_handler(world=sim.world) 697 698 # Update systems 699 if not args.headless: 700 sim.active_camera.manual_movement() 701 fpv_system(world=sim.world) 702 703 if sim.state.display_uav_trace: 704 robots = sim.world.query(HasFPV, HasPose) 705 for robot_id, pose in zip(robots.entity_ids, robots.pose): 706 robot_traces = sim.state.uav_traces.setdefault(int(robot_id), FixedSizeDict(maxlen=UAV_TRACES_MAX_LEN)) 707 robot_traces[tuple(pose[0:3, 3].tolist())] = True 708 709 # Physics systems + the plugins callbacks before/after it 710 prev = time.perf_counter() 711 sim.plugins.on_before_physics(sim.world) 712 for _ in clock.drain(): 713 physics_system(world=sim.world, dt=DT, cell_size=sim.state.collision_cell_size) 714 sim.plugins.on_after_physics(sim.world) 715 sim.physics_ticks_stats.append(time.perf_counter() - prev) 716 717 # Rendering: first fpv cameras (for each robot), then the main UI camera 718 prev = time.perf_counter() 719 sim.render_fpv_cameras(extra_drawables=sim.plugins) 720 if not args.headless: 721 sim.render_main_camera(extra_drawables=sim.plugins.plugins) 722 sim.render_ticks_stats.append(time.perf_counter() - prev) 723 724 sim.plugins.responses_handler(world=sim.world) 725 726 rl.CloseWindow() 727 728if __name__ == "__main__": 729 parser = ArgumentParser() 730 parser.add_argument("--map_path", type=Path, help="Path to the map config (scene objects, not robots). " 731 "If not set, manual scene is built") 732 parser.add_argument("--headless", action="store_true", help="If set, raylib is started in headless mode") 733 parser.add_argument("--port", "-p", type=int, help="The port for the TCP listener", default=42069) 734 parser.add_argument("--n_robots", type=int, default=2, help="The maximum number of robots (connectable) allowed") 735 parser.add_argument("--robot_models", nargs="+", help="A list of models for robots. If not set, uses all available") 736 parser.add_argument("--plugins", nargs="*", default=["manual_move"], 737 help=f"Which plugins to start, if any. Registered: {list(PluginsManager.REGISTERED_PLUGINS)}") 738 parser.add_argument("--protocol_path", type=Path, default=get_project_root() / "server/protocol.json") 739 arg = parser.parse_args() 740 assert len(set(arg.plugins)) == len(arg.plugins), f"duplicates in --plugins: {arg.plugins}" 741 if arg.robot_models is None: 742 arg.robot_models = [x.name for x in (RESOURCES_PATH / "models").iterdir() 743 if x.name.startswith("drone") and x.name != "drone_cube_head"] 744 logger.info(f"--robot_models not provided, using all found: {arg.robot_models}") 745 assert arg.protocol_path.exists(), f"Protocol file (protocol.json) not found at: '{arg.protocol_path}'" 746 main(arg)
74class GroundFloor(Component): 75 """component used to tag the floor so we don't render it in collision mode"""
component used to tag the floor so we don't render it in collision mode
81def world_to_dict(world: World) -> dict[str, Any]: 82 """Serialize the world. Goes through all the entities and their components and converts the serializables to dict""" 83 res = {"entities": [], "components": world.component_names, "extra_metadata": world.extra_metadata} 84 for eid in world.live_entities.keys(): 85 res["entities"].append({"entity_id": eid, **world.get_entity(eid).to_dict(serialization_field="serializable")}) 86 return res
Serialize the world. Goes through all the entities and their components and converts the serializables to dict
88def world_from_dict(data: dict[str, Any]) -> World: 89 """Creates a world from a serialized representation e.g. from world_to_dict()""" 90 components = [{c.__name__: c for c in ALL_COMPONENTS}[c] for c in data["components"]] 91 world = World(components=components, extra_metadata=data["extra_metadata"]) 92 93 for entity in data["entities"]: 94 entity_data = entity["data"] 95 entity_components = [{c.__name__: c for c in ALL_COMPONENTS}[c] for c in entity["components"]] 96 assert "_type" in entity["data"], f"_type not in serialized data for {entity} (id: {entity['entity_id']}" 97 98 if entity_data["_type"]["type"] == "cube": 99 texture_path = get_project_root() / tp if (tp := entity_data["_args"]["texture_path"]) is not None else None 100 eid = make_cube(world, pose=np.float32(entity_data["pose"]), scale=entity_data["_args"]["scale"], 101 size=np.float32(entity_data["_args"]["size"]), texture_path=texture_path) 102 if GroundFloor in entity_components: 103 world.update() 104 world.get_entity(eid).add_component(GroundFloor) 105 106 elif entity_data["_type"]["type"] == "mesh": 107 texture_path = get_project_root() / tp if (tp := entity_data["_args"]["texture_path"]) is not None else None 108 collidable = HasCollision in entity_components 109 make_mesh(world, pose=np.float32(entity_data["pose"]), scale=entity_data["_args"]["scale"], 110 model_path=get_project_root() / entity_data["_args"]["model_path"], texture_path=texture_path, 111 collidable=collidable) 112 113 elif entity_data["_type"]["type"] == "robot": 114 kwargs = {} 115 if HasPhysicsLevel1 in entity_components: 116 kwargs = { 117 "physics_type": "level1", 118 "max_velocities": np.float32(entity_data["max_velocities"]), 119 "velocity": np.float32(entity_data["velocity"]), 120 "acceleration": np.float32(entity_data["acceleration"]), 121 } 122 elif HasPhysicsLevel2 in entity_components: 123 kwargs = { 124 "physics_type": "level2", 125 "drag_coefficient": np.float32(entity_data["drag_coefficient"]), 126 "max_accelerations": np.float32(entity_data["max_accelerations"]), 127 "velocity": np.float32(entity_data["velocity"]), 128 "acceleration": np.float32(entity_data["acceleration"]), 129 } 130 131 make_robot(world, pose=np.float32(entity_data["pose"]), 132 scale=entity_data["_args"]["scale"], 133 model_path=get_project_root() / entity_data["_args"]["model_path"], 134 **kwargs) 135 else: 136 raise NotImplementedError(entity_data["_type"]["type"]) 137 # TODO: we should assert here that all the newly added entities have the same components as these that were in the 138 # previous state. This means we need to do world.update() and validate each added component. 139 return world
Creates a world from a serialized representation e.g. from world_to_dict()
143def build_default_scene_objects() -> World: 144 """builds the default basic scene objects for the simulator: n robots + a few obstacles""" 145 # NOTE: if plugins add their own components, ideally they should be in a separate plugin-owned world! Not enforced.. 146 world = World(components=ALL_COMPONENTS, extra_metadata=["serializable", "comment"]) 147 148 crate_png = RESOURCES_PATH / "textures/crate.png" 149 grass_png = RESOURCES_PATH / "textures/grass.png" 150 151 # Ground plane 152 eid = make_cube(world, pte(make_arr(0, 0, 0, 0, 0, 0)), make_arr(20, 0.1, 20), scale=1, texture_path=grass_png) 153 world.update() 154 world.get_entity(eid).add_component(GroundFloor) 155 # Some scattered cubes at ground level 156 make_cube(world, pte(make_arr(5.0, 0.5, 5.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 157 make_cube(world, pte(make_arr(5.0, 0.5, -3.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 158 make_cube(world, pte(make_arr(-4.0, 0.5, 2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 159 make_cube(world, pte(make_arr(-6.0, 0.5, -5.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 160 make_cube(world, pte(make_arr(8.0, 0.5, 0.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 161 # A small tower (stacked cubes) 162 make_cube(world, pte(make_arr(-2.0, 0.5, -2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 163 make_cube(world, pte(make_arr(-2.0, 1.5, -2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 164 make_cube(world, pte(make_arr(-2.0, 2.5, -2.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 165 # Floating obstacles (for UAV to navigate around) 166 make_cube(world, pte(make_arr(3.0, 3.0, 3.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 167 make_cube(world, pte(make_arr(-3.0, 4.0, 0.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 168 make_cube(world, pte(make_arr(0.0, 5.0, -4.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 169 # Directly in UAV's POV at start (UAV at y=6, looking +Z) 170 make_cube(world, pte(make_arr(0.0, 5.5, 8.0, 0, 0, 0)), make_arr(1.0, 1.0, 1.0), scale=1, texture_path=crate_png) 171 # House model 172 make_mesh(world, RESOURCES_PATH / "models/house/house.obj", pte(make_arr(5, 2, 0, 0, 0, 0)), 173 scale=0.1, collidable=False) 174 return world
builds the default basic scene objects for the simulator: n robots + a few obstacles
176def build_robots(world: World, n_robots: int, robot_models: list[str]): 177 """build the robots in the scene""" 178 _, c = get_closest_square(n_robots) 179 model_paths = [RESOURCES_PATH / "models" / x / f"{x}.obj" for x in robot_models] 180 scales = [0.8] * len(model_paths) 181 182 # physics_type = "level1" 183 # kwargs = { 184 # "max_velocities": np.float32(LVL1_MAX_VELOCITIES), 185 # } 186 physics_type = "level2" 187 kwargs = { 188 "max_accelerations": np.float32(LVL2_MAX_ACCELERATIONS), 189 "drag_coefficient": np.float32((LVL2_DRAG_COEFF, )), 190 } 191 192 for i in range(n_robots): 193 pose = pose_from_position_target_up(position=make_arr(i % c, 6, i // c), 194 target=make_arr(i % c, 6, i // c + 1), up=make_arr(0, 1, 0)) 195 model_path = model_paths[i % len(model_paths)] 196 scale = scales[i % len(model_paths)] 197 make_robot(world, model_path, pose, scale, physics_type, **kwargs)
build the robots in the scene
201@dataclass(kw_only=True) 202class SimState(Serializable): 203 """The state of the scene including the cameras, robot and so on""" 204 world_camera: Camera | None = None 205 topdown_camera: Camera | None = None 206 active_camera_type: str = "world" # Which camera is active based on it's type 207 fpv_ix: int = 0 208 wireframe_mode: bool = False 209 collision_render_mode: bool = False 210 collision_cell_size: Point3D = field(default_factory=lambda: make_arr(30, 30, 30)) 211 display_uav_trace: bool = False 212 uav_traces: dict[EntityId, FixedSizeDict[tuple[float, float, float], bool]] = field(default_factory=dict) 213 214 def __post_init__(self): 215 if self.world_camera is None: 216 self.world_camera = Camera(make_arr(15, 12, 15), make_arr(0, 2, 0), make_arr(0, 1, 0), 217 fovy=45, projection=rl.CAMERA_PERSPECTIVE) 218 if self.topdown_camera is None: 219 self.topdown_camera = Camera(make_arr(0, 30, 0), make_arr(0, 0, 0), make_arr(0, 0, -1), 220 fovy=20, projection=rl.CAMERA_ORTHOGRAPHIC) 221 222 @property 223 def active_camera_label(self) -> str: 224 """returns a string representation of the active camera for HUD""" 225 match self.active_camera_type: 226 case "world": return "WORLD CAMERA (F1)" 227 case "topdown": return "TOP-DOWN CAMERA (F2)" 228 case "fpv": return "FPV CAMERA (F3)" 229 case _: raise ValueError(self.active_camera_type) 230 231 @overrides 232 def to_dict(self) -> dict: 233 return { 234 "world_camera": self.world_camera.to_dict(), 235 "topdown_camera": self.topdown_camera.to_dict(), 236 "active_camera_type": self.active_camera_type, 237 "fpv_ix": self.fpv_ix, 238 "wireframe_mode": self.wireframe_mode, 239 "collision_render_mode": self.collision_render_mode, 240 "collision_cell_size": self.collision_cell_size.tolist(), 241 "display_uav_trace": self.display_uav_trace, 242 "uav_traces": FixedSizeDict({k: list(v.keys()) for k, v in self.uav_traces.items()}, 243 maxlen=UAV_TRACES_MAX_LEN), 244 } 245 246 @staticmethod 247 @overrides 248 def from_dict(state: dict) -> SimState: 249 return SimState( 250 world_camera=Camera.from_dict(state["world_camera"]), 251 topdown_camera=Camera.from_dict(state["topdown_camera"]), 252 active_camera_type=state["active_camera_type"], 253 fpv_ix=state["fpv_ix"], 254 wireframe_mode=state["wireframe_mode"], 255 collision_render_mode=state["collision_render_mode"], 256 collision_cell_size=np.float32(state["collision_cell_size"]), 257 display_uav_trace=state["display_uav_trace"], 258 uav_traces={int(eid): FixedSizeDict({tuple(pt): True for pt in points}, maxlen=UAV_TRACES_MAX_LEN) 259 for eid, points in state["uav_traces"].items()}, 260 )
The state of the scene including the cameras, robot and so on
222 @property 223 def active_camera_label(self) -> str: 224 """returns a string representation of the active camera for HUD""" 225 match self.active_camera_type: 226 case "world": return "WORLD CAMERA (F1)" 227 case "topdown": return "TOP-DOWN CAMERA (F2)" 228 case "fpv": return "FPV CAMERA (F3)" 229 case _: raise ValueError(self.active_camera_type)
returns a string representation of the active camera for HUD
231 @overrides 232 def to_dict(self) -> dict: 233 return { 234 "world_camera": self.world_camera.to_dict(), 235 "topdown_camera": self.topdown_camera.to_dict(), 236 "active_camera_type": self.active_camera_type, 237 "fpv_ix": self.fpv_ix, 238 "wireframe_mode": self.wireframe_mode, 239 "collision_render_mode": self.collision_render_mode, 240 "collision_cell_size": self.collision_cell_size.tolist(), 241 "display_uav_trace": self.display_uav_trace, 242 "uav_traces": FixedSizeDict({k: list(v.keys()) for k, v in self.uav_traces.items()}, 243 maxlen=UAV_TRACES_MAX_LEN), 244 }
the dict representation of this object for serialization purposes
246 @staticmethod 247 @overrides 248 def from_dict(state: dict) -> SimState: 249 return SimState( 250 world_camera=Camera.from_dict(state["world_camera"]), 251 topdown_camera=Camera.from_dict(state["topdown_camera"]), 252 active_camera_type=state["active_camera_type"], 253 fpv_ix=state["fpv_ix"], 254 wireframe_mode=state["wireframe_mode"], 255 collision_render_mode=state["collision_render_mode"], 256 collision_cell_size=np.float32(state["collision_cell_size"]), 257 display_uav_trace=state["display_uav_trace"], 258 uav_traces={int(eid): FixedSizeDict({tuple(pt): True for pt in points}, maxlen=UAV_TRACES_MAX_LEN) 259 for eid, points in state["uav_traces"].items()}, 260 )
loads this object from a serialized dict representation
262class Simulator(Restorable): 263 """The simulator object. Contains the scene (objects/robots), state and events. Wires to/from_dict as well.""" 264 def __init__(self, world: World, state: SimState, connection_manager: ConnectionManager, 265 plugins: PluginsManager, protocol: Protocol): 266 self.world = world 267 self.state = state 268 self.connection_manager = connection_manager 269 self.plugins = plugins 270 self.protocol = protocol 271 272 self.physics_ticks_stats = deque(maxlen=TICK_STATS_LEN) 273 self.render_ticks_stats = deque(maxlen=TICK_STATS_LEN) 274 275 self.world.update() 276 self.robot_eids = self.world.query(HasFPV).entity_ids.copy() 277 self.channel_id_to_robot_eid: dict[int, int] = {} 278 for i, (channel, eid) in enumerate(zip(self.channels, self.robot_eids)): # network channels are not part of ECS 279 self.world.get_entity(eid).add_component(HasChannel, channel=np.array([channel], "object")) 280 self.channel_id_to_robot_eid[i] = eid 281 282 self.world.update() 283 284 @property 285 def channels(self) -> list[ObjToSimChannel]: 286 """The communication channels that this simulator has""" 287 return self.connection_manager.channels 288 289 def render_main_camera(self, extra_drawables: list[Drawable] | None): 290 """renders the main camera (the raylib UI) of the simulator""" 291 rl.BeginDrawing() 292 rl.ClearBackground(DEFAULT_BACKGROUND) 293 294 rl.BeginMode3D(self.active_camera.camera[0]) 295 296 if self.state.wireframe_mode: 297 rl.rlEnableWireMode() 298 299 if self.state.collision_render_mode: 300 self._render_collision_mode(extra_drawables) 301 else: 302 entity_id = self.robot_eids[self.state.fpv_ix].item() if self.state.active_camera_type == "fpv" else None 303 self._render(entity_id=entity_id, extra_drawables=extra_drawables) 304 305 if self.state.display_uav_trace: 306 for traces in self.state.uav_traces.values(): 307 for trace in traces.keys(): 308 rl.DrawSphere(trace, 0.05, rl.GREEN) 309 310 rl.EndMode3D() 311 312 if self.state.wireframe_mode: 313 rl.rlDisableWireMode() 314 315 n_robots = len(self.world.query(HasFPV)) 316 msg = f"{self.state.active_camera_label}\nObjects: {len(self.world)} (robots: {n_robots})" 317 rl.DrawText(msg.encode(), 10, 10, 20, rl.DARKGRAY) 318 rl.DrawFPS(rl.GetScreenWidth() - 80, 10) 319 rl.EndDrawing() 320 321 def render_fpv_cameras(self, extra_drawables: list[Drawable] | None): 322 """Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.""" 323 qr = self.world.query(HasChannel, HasFPV) 324 for i, (channel, fpv_camera, fpv_texture, fpv_data) in enumerate(zip(qr.channel, qr.fpv_camera, 325 qr.fpv_texture, qr.fpv_data)): 326 if channel.item().client is None: 327 continue 328 fpv_texture: "rl.RenderTexture" = fpv_texture.item() 329 fpv_data: FPVData = fpv_data.item() 330 camera: Camera = fpv_camera.item() 331 332 rl.BeginTextureMode(fpv_texture) 333 rl.ClearBackground(DEFAULT_BACKGROUND) 334 rl.BeginMode3D(camera.camera[0]) 335 self._render(entity_id=qr.entity_ids[i].item(), extra_drawables=extra_drawables) 336 rl.EndMode3D() 337 rl.EndTextureMode() 338 339 fpv_img = rl.LoadImageFromTexture(fpv_texture.texture) 340 fpv_img_ptr = rl.ffi.new("Image *", fpv_img) 341 rl.ImageFlipVertical(fpv_img_ptr) 342 with fpv_data.lock: 343 fpv_data.frame = bytes(rl.ffi.buffer(fpv_img_ptr.data, len(fpv_data.frame))) 344 fpv_data.frame_id += 1 345 fpv_data._frame_compressed = None # pylint: disable=protected-access 346 rl.UnloadImage(fpv_img_ptr[0]) 347 348 @property 349 def active_camera(self) -> Camera: 350 """The current active camera out of the 3: world, uav or topdown""" 351 match self.state.active_camera_type: 352 case "world": return self.state.world_camera 353 case "topdown": return self.state.topdown_camera 354 case "fpv": return self.world.get_entity( 355 self.world.query(HasFPV).entity_ids[self.state.fpv_ix]).fpv_camera[0] 356 case _: raise ValueError(self.state.active_camera_type) 357 358 @overrides 359 def to_dict(self) -> dict: 360 return { 361 "world": world_to_dict(self.world), 362 "state": self.state.to_dict(), 363 "plugins": self.plugins.to_dict(), 364 } 365 366 @overrides 367 def load_state_dict(self, state: dict): 368 state = deepcopy(state) # this is mostly for INIT_STATE as the code below mutates it while loading 369 370 self._load_world_from_dict(state["world"]) 371 self.state = SimState.from_dict(state["state"]) 372 373 old_names = set(self.plugins.plugin_names) 374 for dropped in old_names - state["plugins"].keys(): 375 logger.warning(f"Plugin '{dropped}' was live but absent from loaded state. Dropping.") 376 self.plugins = PluginsManager.from_dict(state["plugins"]) 377 378 def _load_world_from_dict(self, world_state: dict): 379 world = world_from_dict(world_state) 380 world.update() 381 382 self.robot_eids = world.query(HasFPV).entity_ids.copy() 383 assert (A:=len(self.robot_eids)) == (B:=len(self.channels)), f"Loaded robots: {A} doesn't match existing: {B}" 384 for i, (channel, eid) in enumerate(zip(self.channels, self.robot_eids)): # network channels must be updated too 385 world.get_entity(eid).add_component(HasChannel, channel=np.array([channel], "object")) 386 self.channel_id_to_robot_eid[i] = eid 387 world.update() 388 389 logger.info(f"Loaded world from state: {world}") 390 self.world = world 391 392 def _render(self, entity_id: int | None, extra_drawables: list[Drawable] | None): #noqa 393 """Renders the scene and all its objects + robots. Called from within a rl.BeginMode3D() block.""" 394 qr = self.world.query(HasModel, HasPose) 395 for i, (model, pose, scale) in enumerate(zip(qr.model, qr.pose, qr.scale)): 396 if entity_id is not None and entity_id == qr.entity_ids[i]: # if in FPV mode then don't draw yourself 397 continue 398 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 399 400 for drawable in (extra_drawables or []): # e.g. plugins 401 drawable.draw(self.world, entity_id) 402 403 def _render_collision_mode(self, extra_drawables: list[Drawable] | None): 404 """render the scene in 'collision mode' which means we don't render the meshes of collidables, but the shapes""" 405 qr = self.world.query(HasModel, HasPose, exclude=[HasCollision]) 406 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 407 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 408 409 qr = self.world.query(HasModel, HasPose, HasCollision, exclude=[GroundFloor]) 410 collider_size = qr.collider_bbox[:, 1] - qr.collider_bbox[:, 0] 411 for pose, kind, radius, size, colliding in zip(qr.pose, qr.collider_kind, qr.collider_radii, 412 collider_size, qr.is_colliding): 413 color = rl.RED if colliding else rl.GREEN 414 if kind == ColliderKinds.SPHERE: 415 rl.DrawSphere(pose[0:3, 3].tolist(), radius.item(), color) 416 elif kind == ColliderKinds.AABB: 417 rl.DrawCube(pose[0:3, 3].tolist(), *size.tolist(), color) 418 else: 419 raise NotImplementedError(kind) 420 421 qr = self.world.query(HasModel, HasPose, GroundFloor) 422 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 423 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 424 425 for drawable in (extra_drawables or []): # e.g. plugins 426 drawable.draw(self.world, None) 427 428 def __repr__(self): 429 return f"[Simulator]\n{self.world}\n{self.plugins}\n{self.connection_manager}\n{self.protocol}"
The simulator object. Contains the scene (objects/robots), state and events. Wires to/from_dict as well.
264 def __init__(self, world: World, state: SimState, connection_manager: ConnectionManager, 265 plugins: PluginsManager, protocol: Protocol): 266 self.world = world 267 self.state = state 268 self.connection_manager = connection_manager 269 self.plugins = plugins 270 self.protocol = protocol 271 272 self.physics_ticks_stats = deque(maxlen=TICK_STATS_LEN) 273 self.render_ticks_stats = deque(maxlen=TICK_STATS_LEN) 274 275 self.world.update() 276 self.robot_eids = self.world.query(HasFPV).entity_ids.copy() 277 self.channel_id_to_robot_eid: dict[int, int] = {} 278 for i, (channel, eid) in enumerate(zip(self.channels, self.robot_eids)): # network channels are not part of ECS 279 self.world.get_entity(eid).add_component(HasChannel, channel=np.array([channel], "object")) 280 self.channel_id_to_robot_eid[i] = eid 281 282 self.world.update()
284 @property 285 def channels(self) -> list[ObjToSimChannel]: 286 """The communication channels that this simulator has""" 287 return self.connection_manager.channels
The communication channels that this simulator has
289 def render_main_camera(self, extra_drawables: list[Drawable] | None): 290 """renders the main camera (the raylib UI) of the simulator""" 291 rl.BeginDrawing() 292 rl.ClearBackground(DEFAULT_BACKGROUND) 293 294 rl.BeginMode3D(self.active_camera.camera[0]) 295 296 if self.state.wireframe_mode: 297 rl.rlEnableWireMode() 298 299 if self.state.collision_render_mode: 300 self._render_collision_mode(extra_drawables) 301 else: 302 entity_id = self.robot_eids[self.state.fpv_ix].item() if self.state.active_camera_type == "fpv" else None 303 self._render(entity_id=entity_id, extra_drawables=extra_drawables) 304 305 if self.state.display_uav_trace: 306 for traces in self.state.uav_traces.values(): 307 for trace in traces.keys(): 308 rl.DrawSphere(trace, 0.05, rl.GREEN) 309 310 rl.EndMode3D() 311 312 if self.state.wireframe_mode: 313 rl.rlDisableWireMode() 314 315 n_robots = len(self.world.query(HasFPV)) 316 msg = f"{self.state.active_camera_label}\nObjects: {len(self.world)} (robots: {n_robots})" 317 rl.DrawText(msg.encode(), 10, 10, 20, rl.DARKGRAY) 318 rl.DrawFPS(rl.GetScreenWidth() - 80, 10) 319 rl.EndDrawing()
renders the main camera (the raylib UI) of the simulator
321 def render_fpv_cameras(self, extra_drawables: list[Drawable] | None): 322 """Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.""" 323 qr = self.world.query(HasChannel, HasFPV) 324 for i, (channel, fpv_camera, fpv_texture, fpv_data) in enumerate(zip(qr.channel, qr.fpv_camera, 325 qr.fpv_texture, qr.fpv_data)): 326 if channel.item().client is None: 327 continue 328 fpv_texture: "rl.RenderTexture" = fpv_texture.item() 329 fpv_data: FPVData = fpv_data.item() 330 camera: Camera = fpv_camera.item() 331 332 rl.BeginTextureMode(fpv_texture) 333 rl.ClearBackground(DEFAULT_BACKGROUND) 334 rl.BeginMode3D(camera.camera[0]) 335 self._render(entity_id=qr.entity_ids[i].item(), extra_drawables=extra_drawables) 336 rl.EndMode3D() 337 rl.EndTextureMode() 338 339 fpv_img = rl.LoadImageFromTexture(fpv_texture.texture) 340 fpv_img_ptr = rl.ffi.new("Image *", fpv_img) 341 rl.ImageFlipVertical(fpv_img_ptr) 342 with fpv_data.lock: 343 fpv_data.frame = bytes(rl.ffi.buffer(fpv_img_ptr.data, len(fpv_data.frame))) 344 fpv_data.frame_id += 1 345 fpv_data._frame_compressed = None # pylint: disable=protected-access 346 rl.UnloadImage(fpv_img_ptr[0])
Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.
348 @property 349 def active_camera(self) -> Camera: 350 """The current active camera out of the 3: world, uav or topdown""" 351 match self.state.active_camera_type: 352 case "world": return self.state.world_camera 353 case "topdown": return self.state.topdown_camera 354 case "fpv": return self.world.get_entity( 355 self.world.query(HasFPV).entity_ids[self.state.fpv_ix]).fpv_camera[0] 356 case _: raise ValueError(self.state.active_camera_type)
The current active camera out of the 3: world, uav or topdown
358 @overrides 359 def to_dict(self) -> dict: 360 return { 361 "world": world_to_dict(self.world), 362 "state": self.state.to_dict(), 363 "plugins": self.plugins.to_dict(), 364 }
the dict representation of this object for serialization purposes
366 @overrides 367 def load_state_dict(self, state: dict): 368 state = deepcopy(state) # this is mostly for INIT_STATE as the code below mutates it while loading 369 370 self._load_world_from_dict(state["world"]) 371 self.state = SimState.from_dict(state["state"]) 372 373 old_names = set(self.plugins.plugin_names) 374 for dropped in old_names - state["plugins"].keys(): 375 logger.warning(f"Plugin '{dropped}' was live but absent from loaded state. Dropping.") 376 self.plugins = PluginsManager.from_dict(state["plugins"])
Loads in place this object from a serialized dict representation
433def keyboard_handler(sim: Simulator): 434 """handle keyboard input for the main UI. We create a message in the same protocol of robosim-ncat/global hanlder""" 435 msg = None 436 437 # camera management 438 if rl.IsKeyPressed(rl.KEY_F1): 439 msg = {"cmd": "sim_set_camera", "type": "world", "id": 0} # dummy ids, needed for protocol 440 if rl.IsKeyPressed(rl.KEY_F2): 441 msg = {"cmd": "sim_set_camera", "type": "topdown", "id": 0} # dummy ids, needed for protocol 442 if rl.IsKeyPressed(rl.KEY_F3): # we still keep the 'fpv' camera here but cannot control it. 443 if sim.state.active_camera_type == "fpv": 444 sim.state.fpv_ix = (sim.state.fpv_ix + 1) % len(sim.world.query(HasFPV)) 445 msg = {"cmd": "sim_set_camera", "type": "fpv", "id": sim.state.fpv_ix} 446 447 # sim state management 448 if rl.IsKeyPressed(rl.KEY_F5): # save the state of the scene on the server side 449 msg = {"cmd": "sim_save_state"} 450 if rl.IsKeyPressed(rl.KEY_F6): # load the state of the scene on the server side 451 try: 452 with open(RESOURCES_PATH / "state.json", "r") as fp: 453 state = json.load(fp) 454 msg = {"cmd": "sim_load_state", "state": state} 455 except Exception as e: 456 logger.error(str(e)) 457 if rl.IsKeyPressed(rl.KEY_R): 458 msg = {"cmd": "sim_load_state", "state": INIT_CONFIG} 459 460 # render management 461 if rl.IsKeyPressed(rl.KEY_I): 462 msg = {"cmd": "sim_set_wireframe_mode", "value": not sim.state.wireframe_mode} 463 if rl.IsKeyPressed(rl.KEY_O): 464 msg = {"cmd": "sim_set_collision_render_mode", "value": not sim.state.collision_render_mode} 465 466 # move robot via keyboard 467 if sim.state.active_camera_type == "fpv": 468 entity_id = sim.robot_eids[sim.state.fpv_ix] 469 control_input = np.float32([ 470 rl.IsKeyDown(rl.KEY_A) - rl.IsKeyDown(rl.KEY_D), # left/right (x) 471 rl.IsKeyDown(rl.KEY_PAGE_UP) - rl.IsKeyDown(rl.KEY_PAGE_DOWN), # up/down (y) 472 rl.IsKeyDown(rl.KEY_W) - rl.IsKeyDown(rl.KEY_S), # forward/backward (z) 473 rl.IsKeyDown(rl.KEY_DOWN) - rl.IsKeyDown(rl.KEY_UP), # pitch 474 rl.IsKeyDown(rl.KEY_Q) - rl.IsKeyDown(rl.KEY_E), # yaw 475 rl.IsKeyDown(rl.KEY_RIGHT) - rl.IsKeyDown(rl.KEY_LEFT), # roll 476 ]) 477 if (control_input != 0).any(): 478 msg = {"cmd": "entity_move", "entity_id": entity_id.item(), "control_input": control_input} 479 480 # misc / other commands 481 if rl.IsKeyPressed(rl.KEY_F9): 482 msg = {"cmd": "sim_set_collision_cell_size", "value": np.maximum(sim.state.collision_cell_size - 0.1, 0.1)} 483 if rl.IsKeyPressed(rl.KEY_F10): 484 msg = {"cmd": "sim_set_collision_cell_size", "value": np.minimum(sim.state.collision_cell_size + 0.1, 30)} 485 if rl.IsKeyPressed(rl.KEY_T): 486 msg = {"cmd": "sim_display_uav_trace", "value": not sim.state.display_uav_trace} 487 488 if msg is not None: 489 res = _handle_msg(sim, msg) 490 if "error" in res: 491 logger.error(res["error"]) 492 else: 493 if msg["cmd"] != "entity_move": 494 logger.debug(res["status"])
handle keyboard input for the main UI. We create a message in the same protocol of robosim-ncat/global hanlder
496def network_handler(msg: dict, global_channel: ObjToSimChannel, connected_channel: ObjToSimChannel | None, 497 sim: Simulator) -> dict | None: 498 """Ran in TCP thread. Fast path for one-off messages. If not fast -> sends to the right channel (global/conn). 499 Returns None if the message requires to be authenticated ('connect'), but the client isn't (channel is None)""" 500 assert "cmd" in msg, msg # should be handlded in connection manager already 501 502 all_cmds = [*list(sim.protocol.endpoints), *sim.plugins.all_endpoints] 503 if (cmd := msg["cmd"]) not in all_cmds: 504 return {"error": f"Unknown command: '{cmd}'", "supported_commands": all_cmds} 505 506 # these will go in the plugins manager and get handled by the right plugin 507 if cmd in sim.plugins.all_endpoints: 508 # If the client is not connected (e.g. controls a robot), then the handler returns and client gets disconnected 509 # TODO: it may be the case that some plugin messages should work even non connected (e.g. mission_get_state) 510 if connected_channel is None: 511 return None 512 513 with connected_channel.lock: 514 connected_channel.tcp2sim.put(msg) 515 return connected_channel.sim2tcp.get(timeout=SOCKET_TIMEOUT_S) 516 517 # guaranteed to be an admin/global command here 518 519 if (err := sim.protocol.validate_endpoint(endpoint=cmd, data=msg)) is not None: 520 logger.error(err) 521 return {"error": err.error} 522 523 if cmd == "help": 524 return {"supported_commands": all_cmds} 525 if cmd == "sim_get_info": 526 return {"control_loop_rate_hz": HZ, 527 "physics_tick_s": {"window": len(pts := sim.physics_ticks_stats), "p50": np.median(pts).item()}, 528 "render_tick_s": {"window": len(rts := sim.render_ticks_stats), "p50": np.median(rts).item()},} 529 if cmd == "sim_get_state": 530 return sim.to_dict() 531 532 if cmd in ("robot_get_state", "robot_get_state_with_camera"): 533 channel = sim.channels[robot_ix := msg["robot_ix"]] 534 robot_eid = sim.channel_id_to_robot_eid[robot_ix] 535 robot_entity = sim.world.get_entity(robot_eid) 536 robot_state = robot_entity.to_dict(serialization_field="serializable") 537 robot_state["connected"] = channel.client is not None 538 fpv_data: FPVData = robot_entity.fpv_data.item() 539 540 if cmd == "robot_get_state": 541 return {"robot": robot_state} 542 543 if robot_state["connected"] is False: 544 return {"error": f"Robot '{robot_ix}' is not connected. Cannot get FPV data"} 545 546 with fpv_data.lock: 547 res = {"robot": robot_state, "fpv_compressed": (fc := fpv_data.frame_compressed), 548 "fpv_shape": (fs := fpv_data.frame_shape), "fpv_frame_id": fpv_data.frame_id} 549 logger.log_every_s(f"FPV: {len(fc)} bytes (cr {len(fc)/(np.prod(fs)*3/4)*100:.2f}%)", "DEBUG", True) # RGB 550 return res 551 552 # These need to run on the main thread, so we use a special channel 553 with global_channel.lock: 554 global_channel.tcp2sim.put(msg) 555 return global_channel.sim2tcp.get(timeout=SOCKET_TIMEOUT_S)
Ran in TCP thread. Fast path for one-off messages. If not fast -> sends to the right channel (global/conn). Returns None if the message requires to be authenticated ('connect'), but the client isn't (channel is None)
642def global_handler(sim: Simulator): 643 """Ran in main thread. Handles the admin commands that do not run in the 'fast path' & need main thread (opengl)""" 644 try: 645 msg = sim.connection_manager.global_channel.tcp2sim.get_nowait() 646 except Empty: 647 return 648 sim.connection_manager.global_channel.sim2tcp.put(_handle_msg(sim, msg))
Ran in main thread. Handles the admin commands that do not run in the 'fast path' & need main thread (opengl)
652def main(args: Namespace): 653 """main fn""" 654 global INIT_CONFIG # pylint: disable=global-statement 655 rl.SetConfigFlags(rl.FLAG_WINDOW_HIDDEN | rl.FLAG_WINDOW_UNDECORATED if args.headless else rl.FLAG_WINDOW_RESIZABLE) 656 rl.InitWindow(SCREEN_WIDTH, SCREEN_HEIGHT, b"UAV Trajectory Simulation") 657 rl.SetWindowPosition(20, 200) 658 rl.SetTargetFPS(0) 659 rl.DisableEventWaiting() 660 rl.rlEnableBackfaceCulling() 661 662 if args.map_path is None: 663 world = build_default_scene_objects() 664 else: 665 with open(args.map_path, "r") as fp: 666 world = world_from_dict(json.load(fp)["world"]) 667 build_robots(world, args.n_robots, args.robot_models) 668 669 state = SimState(collision_cell_size=make_collision_cell_size(world)) 670 conn_manager = ConnectionManager("0.0.0.0", args.port, n_channels=args.n_robots, connect_command=CONNECT_CMD) 671 672 plugins = PluginsManager(plugin_names=args.plugins) 673 with open(args.protocol_path, "r") as fp: 674 protocol = Protocol.from_dict(json.load(fp), max_robot_ix=args.n_robots - 1) 675 sim = Simulator(world=world, state=state, connection_manager=conn_manager, plugins=plugins, protocol=protocol) 676 logger.info("Created default map" if args.map_path is None else f"Constructed map from '{args.map_path}'") 677 logger.info(f"Created {len(world.query(HasModel, exclude=[HasFPV]))} objects and {len(world.query(HasFPV))} robots") 678 logger.info(f"Created simulator object:\n{sim}") 679 logger.info(f"Raylib rendering on: {' / '.join(rl_get_device_and_renderer())}") 680 INIT_CONFIG = sim.to_dict() 681 conn_manager.start(partial(network_handler, sim=sim)) 682 683 fpv_system = FPVCameraSystem() 684 physics_system = PhysicsSystem() 685 686 clock = Clock(dt=DT, max_ticks=MAX_SUBTICKS_PER_RENDER_TICK) 687 while True: 688 sim.world.update() 689 clock.wait_and_tick() 690 691 # I/O systems: read messages from the keyboard as well as all robots (via plugins/channels) 692 if rl.IsKeyPressed(rl.KEY_ESCAPE) or not conn_manager.tcp_thread.is_alive(): 693 break 694 sim.world.query(HasMotionInput).motion_input[:] = 0 # reset all the inputs for the physics system 695 keyboard_handler(sim) 696 global_handler(sim) 697 sim.plugins.io_handler(world=sim.world) 698 699 # Update systems 700 if not args.headless: 701 sim.active_camera.manual_movement() 702 fpv_system(world=sim.world) 703 704 if sim.state.display_uav_trace: 705 robots = sim.world.query(HasFPV, HasPose) 706 for robot_id, pose in zip(robots.entity_ids, robots.pose): 707 robot_traces = sim.state.uav_traces.setdefault(int(robot_id), FixedSizeDict(maxlen=UAV_TRACES_MAX_LEN)) 708 robot_traces[tuple(pose[0:3, 3].tolist())] = True 709 710 # Physics systems + the plugins callbacks before/after it 711 prev = time.perf_counter() 712 sim.plugins.on_before_physics(sim.world) 713 for _ in clock.drain(): 714 physics_system(world=sim.world, dt=DT, cell_size=sim.state.collision_cell_size) 715 sim.plugins.on_after_physics(sim.world) 716 sim.physics_ticks_stats.append(time.perf_counter() - prev) 717 718 # Rendering: first fpv cameras (for each robot), then the main UI camera 719 prev = time.perf_counter() 720 sim.render_fpv_cameras(extra_drawables=sim.plugins) 721 if not args.headless: 722 sim.render_main_camera(extra_drawables=sim.plugins.plugins) 723 sim.render_ticks_stats.append(time.perf_counter() - prev) 724 725 sim.plugins.responses_handler(world=sim.world) 726 727 rl.CloseWindow()
main fn