robosim.simulator_singleton
simulator_singleton.py - The simulator singleton that holds the world, the sim state, plugins, protocol etc.
1"""simulator_singleton.py - The simulator singleton that holds the world, the sim state, plugins, protocol etc.""" 2 3from __future__ import annotations 4from dataclasses import dataclass, field 5from copy import deepcopy 6from typing import Any 7from collections import deque 8 9from overrides import overrides 10import numpy as np 11import raylib as rl 12from microecs import World, EntityId, Component 13from microspec import Protocol 14from micronetcode import ConnectionManager 15 16from robolib.camera import Camera 17from robolib.netcode import RobosimClient, RobosimClientState 18from robolib.utils import Point3D,FPVData, logger, make_arr, FixedSizeDict 19from robolib.constants import FPV_HEIGHT, FPV_WIDTH, DEFAULT_BACKGROUND, TICK_STATS_LEN 20from robolib.traits import Restorable, Serializable, Drawable 21from robolib.plugins_manager import PluginsManager 22from robolib.components import ROBOLIB_COMPONENTS, ColliderKinds, HasModel, HasPose, HasFPV, HasCollision 23from robolib.entities import add_entity 24 25from plugins import REGISTERED_PLUGINS 26 27# simulator-specific components (e.g. not generic which whould live in robosim/components). 28# generic ones should have the 'Has' prefix (e.g. HasPose), while non-generic ones usually don't (e.g. GroundFloor) 29 30class GroundFloor(Component): 31 """component used to tag the floor so we don't render it in collision mode""" 32 33ALL_COMPONENTS = [*ROBOLIB_COMPONENTS, GroundFloor] 34 35# serialization / build entities 36 37def world_to_dict(world: World) -> dict[str, Any]: 38 """Serialize the world. Goes through all the entities and their components and converts the serializables to dict""" 39 res = {"entities": [], "components": world.component_names, "extra_metadata": world.extra_metadata} 40 for eid in world.live_entities.keys(): 41 res["entities"].append({"entity_id": eid, **world.get_entity(eid).to_dict(serialization_field="serializable")}) 42 return res 43 44def world_from_dict(data: dict[str, Any]) -> World: 45 """Creates a world from a serialized representation e.g. from world_to_dict()""" 46 components = [{c.__name__: c for c in ALL_COMPONENTS}[c] for c in data["components"]] 47 world = World(components=components, extra_metadata=data["extra_metadata"]) 48 for entity in data["entities"]: 49 components = [world.component_name_to_type[c] for c in entity["components"]] 50 add_entity(world, components, **entity["data"]) 51 return world 52 53# Simulator State object. All relevant state data stays here. This will be serialized and hashed for DST in the future. 54 55@dataclass(kw_only=True) 56class SimState(Serializable): 57 """The state of the scene including the cameras, robot and so on""" 58 world_camera: Camera | None = None 59 topdown_camera: Camera | None = None 60 active_camera_type: str = "world" # Which camera is active based on it's type 61 fpv_ix: int = 0 62 wireframe_mode: bool = False 63 collision_render_mode: bool = False 64 collision_cell_size: Point3D = field(default_factory=lambda: make_arr(30, 30, 30)) 65 display_uav_trace: bool = False 66 uav_traces: dict[EntityId, FixedSizeDict[tuple[float, float, float], bool]] = field(default_factory=dict) 67 uav_traces_max_len: int = 1000 68 69 def __post_init__(self): 70 if self.world_camera is None: 71 self.world_camera = Camera(make_arr(15, 12, 15), make_arr(0, 2, 0), make_arr(0, 1, 0), 72 fovy=45, projection=rl.CAMERA_PERSPECTIVE) 73 if self.topdown_camera is None: 74 self.topdown_camera = Camera(make_arr(0, 30, 0), make_arr(0, 0, 0), make_arr(0, 0, -1), 75 fovy=20, projection=rl.CAMERA_ORTHOGRAPHIC) 76 77 @property 78 def active_camera_label(self) -> str: 79 """returns a string representation of the active camera for HUD""" 80 match self.active_camera_type: 81 case "world": return "WORLD CAMERA (F1)" 82 case "topdown": return "TOP-DOWN CAMERA (F2)" 83 case "fpv": return "FPV CAMERA (F3)" 84 case _: raise ValueError(self.active_camera_type) 85 86 @overrides 87 def to_dict(self) -> dict: 88 return { 89 "world_camera": self.world_camera.to_dict(), 90 "topdown_camera": self.topdown_camera.to_dict(), 91 "active_camera_type": self.active_camera_type, 92 "fpv_ix": self.fpv_ix, 93 "wireframe_mode": self.wireframe_mode, 94 "collision_render_mode": self.collision_render_mode, 95 "collision_cell_size": self.collision_cell_size.tolist(), 96 "display_uav_trace": self.display_uav_trace, 97 "uav_traces": FixedSizeDict({k: list(v.keys()) for k, v in self.uav_traces.items()}, 98 maxlen=self.uav_traces_max_len), 99 "uav_traces_max_len": self.uav_traces_max_len, 100 } 101 102 @staticmethod 103 @overrides 104 def from_dict(state: dict) -> SimState: 105 return SimState( 106 world_camera=Camera.from_dict(state["world_camera"]), 107 topdown_camera=Camera.from_dict(state["topdown_camera"]), 108 active_camera_type=state["active_camera_type"], 109 fpv_ix=state["fpv_ix"], 110 wireframe_mode=state["wireframe_mode"], 111 collision_render_mode=state["collision_render_mode"], 112 collision_cell_size=np.float32(state["collision_cell_size"]), 113 display_uav_trace=state["display_uav_trace"], 114 uav_traces={int(eid): FixedSizeDict({tuple(pt): True for pt in points}, maxlen=state["uav_traces_max_len"]) 115 for eid, points in state["uav_traces"].items()}, 116 uav_traces_max_len=state["uav_traces_max_len"], 117 ) 118 119class Simulator(Restorable): 120 """The simulator object. Contains the scene (objects/robots), state and events. Wires to/from_dict as well.""" 121 def __init__(self, world: World, state: SimState, connection_manager: ConnectionManager, 122 plugins: list[str], protocol: Protocol): 123 self.world = world 124 self.state = state 125 self.connection_manager = connection_manager 126 self.plugins = plugins 127 self.protocol = protocol 128 129 self.plugins_manager = PluginsManager(registered_plugins=REGISTERED_PLUGINS, plugin_names=plugins) 130 self.physics_ticks_stats = deque(maxlen=TICK_STATS_LEN) 131 self.render_ticks_stats = deque(maxlen=TICK_STATS_LEN) 132 133 self.world.update() 134 self.robot_eids: list[EntityId] = self.world.query(HasFPV).entity_ids.tolist() 135 self.robot_eid_to_channel_ix: dict[EntityId, int] = {} 136 self._add_fpv_to_robots() 137 138 self._all_commands: set[str] | None = None # plugins + core commands cache 139 140 @property 141 def active_camera(self) -> Camera: 142 """The current active camera out of the 3: world, uav or topdown""" 143 match self.state.active_camera_type: 144 case "world": return self.state.world_camera 145 case "topdown": return self.state.topdown_camera 146 case "fpv": return self.world.get_entity( 147 self.world.query(HasFPV).entity_ids[self.state.fpv_ix]).fpv_camera[0] 148 case _: raise ValueError(self.state.active_camera_type) 149 150 @property 151 def all_commands(self) -> set[str]: 152 """All the commands (core and plugins) available in the simulator. Must be updated on plugins/world changes""" 153 if self._all_commands is None: 154 self._all_commands = set(self.protocol.endpoints) | self.plugins_manager.all_endpoints 155 return self._all_commands 156 157 def render_main_camera(self, extra_drawables: list[Drawable] | None): 158 """renders the main camera (the raylib UI) of the simulator""" 159 rl.BeginDrawing() 160 rl.ClearBackground(DEFAULT_BACKGROUND) 161 162 rl.BeginMode3D(self.active_camera.camera[0]) 163 164 if self.state.wireframe_mode: 165 rl.rlEnableWireMode() 166 167 if self.state.collision_render_mode: 168 self._render_collision_mode(extra_drawables) 169 else: 170 entity_id = self.robot_eids[self.state.fpv_ix] if self.state.active_camera_type == "fpv" else None 171 self._render(entity_id=entity_id, extra_drawables=extra_drawables) 172 173 if self.state.display_uav_trace: 174 for traces in self.state.uav_traces.values(): 175 for trace in traces.keys(): 176 rl.DrawSphere(trace, 0.05, rl.GREEN) 177 178 rl.EndMode3D() 179 180 if self.state.wireframe_mode: 181 rl.rlDisableWireMode() 182 183 n_robots = len(self.world.query(HasFPV)) 184 msg = f"{self.state.active_camera_label}\nObjects: {len(self.world)} (robots: {n_robots})" 185 rl.DrawText(msg.encode(), 10, 10, 20, rl.DARKGRAY) 186 rl.DrawFPS(rl.GetScreenWidth() - 80, 10) 187 rl.EndDrawing() 188 189 def render_fpv_cameras(self, extra_drawables: list[Drawable] | None): 190 """Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.""" 191 for robot_eid in self.robot_eids: 192 if robot_eid not in self.robot_eid_to_channel_ix: 193 continue 194 entity = self.world.get_entity(robot_eid) 195 fpv_texture: "rl.RenderTexture" = entity.fpv_texture.item() 196 fpv_data: FPVData = entity.fpv_data.item() 197 camera: Camera = entity.fpv_camera.item() 198 199 rl.BeginTextureMode(fpv_texture) 200 rl.ClearBackground(DEFAULT_BACKGROUND) 201 rl.BeginMode3D(camera.camera[0]) 202 self._render(entity_id=robot_eid, extra_drawables=extra_drawables) 203 rl.EndMode3D() 204 rl.EndTextureMode() 205 206 fpv_img = rl.LoadImageFromTexture(fpv_texture.texture) 207 fpv_img_ptr = rl.ffi.new("Image *", fpv_img) 208 rl.ImageFlipVertical(fpv_img_ptr) 209 with fpv_data.lock: 210 fpv_data.frame = bytes(rl.ffi.buffer(fpv_img_ptr.data, len(fpv_data.frame))) 211 fpv_data.frame_id += 1 212 fpv_data._frame_compressed = None # pylint: disable=protected-access 213 rl.UnloadImage(fpv_img_ptr[0]) 214 215 def assign_channel_to_first_free_robot(self, channel_idx: int) -> int | None: 216 """Gets the first free robot eid. Called from protocol._handle_message (slow). TODO: Use a lock ?""" 217 for robot_ix, robot_eid in enumerate(self.robot_eids): 218 if robot_eid not in self.robot_eid_to_channel_ix: 219 self.robot_eid_to_channel_ix[robot_eid] = channel_idx 220 return robot_ix 221 return None 222 223 def client_on_disconnect(self, client: RobosimClient): 224 """Clears the robot that was assigned to this connected client. TODO: use a lock ?""" 225 if client.state == RobosimClientState.CONNECTED: 226 robot_ix = self.robot_eids.index(client.robot_eid) 227 logger.debug(f"Releasing robot {robot_ix} (eid: {client.robot_eid}, channel: {client.channel.idx})") 228 self.robot_eid_to_channel_ix.pop(client.robot_eid) 229 client.state = RobosimClientState.STAGED 230 231 @overrides 232 def to_dict(self) -> dict: 233 return { 234 "world": world_to_dict(self.world), 235 "state": self.state.to_dict(), 236 "plugins": self.plugins_manager.to_dict(), 237 } 238 239 @overrides 240 def load_state_dict(self, state: dict): 241 state = deepcopy(state) # this is mostly for INIT_STATE as the code below mutates it while loading 242 243 self._load_world_from_dict(state["world"]) 244 self.state = SimState.from_dict(state["state"]) 245 246 old_names = set(self.plugins) 247 for dropped in old_names - state["plugins"].keys(): 248 logger.warning(f"Plugin '{dropped}' was live but absent from loaded state. Dropping.") 249 self.plugins = list(state["plugins"]) 250 self.plugins_manager = PluginsManager.from_dict(REGISTERED_PLUGINS, state["plugins"]) 251 self._all_commands = None # Reset this so all_commands is created again and we don't re-use the cache. 252 253 def _add_fpv_to_robots(self): 254 for eid in self.robot_eids: # FPV data is not part of ECS 255 camera = Camera(make_arr(0, 0, 0), make_arr(0, 0, 0), make_arr(0, 0, 0), 60.0, rl.CAMERA_PERSPECTIVE) 256 fpv_data = FPVData(frame=np.zeros((FPV_HEIGHT * FPV_WIDTH * 4,), "uint8"), 257 frame_shape=(FPV_HEIGHT, FPV_WIDTH, 4)) 258 robot = self.world.get_entity(eid) 259 # TODO(microecs-30): set_data(fpv_camera=fpv_camera) and auto-convert on microecs side. 260 robot.set_data(fpv_camera=np.array([camera], "object")) 261 robot.set_data(fpv_texture=np.array([rl.LoadRenderTexture(FPV_WIDTH, FPV_HEIGHT)], "object")) # TODO: dealoc 262 robot.set_data(fpv_data=np.array([fpv_data], "object")) 263 self.world.update() 264 265 def _load_world_from_dict(self, world_state: dict): 266 self.world = world_from_dict(world_state) 267 self.world.update() 268 269 self.robot_eids = self.world.query(HasFPV).entity_ids.tolist() 270 self._add_fpv_to_robots() 271 272 logger.info(f"Loaded world from state: {self.world}") 273 274 def _render(self, entity_id: int | None, extra_drawables: list[Drawable] | None): #noqa 275 """Renders the scene and all its objects + robots. Called from within a rl.BeginMode3D() block.""" 276 qr = self.world.query(HasModel, HasPose) 277 for i, (model, pose, scale) in enumerate(zip(qr.model, qr.pose, qr.scale)): 278 if entity_id is not None and entity_id == qr.entity_ids[i]: # if in FPV mode then don't draw yourself 279 continue 280 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 281 282 for drawable in (extra_drawables or []): # e.g. plugins 283 drawable.draw(self.world, entity_id) 284 285 def _render_collision_mode(self, extra_drawables: list[Drawable] | None): 286 """render the scene in 'collision mode' which means we don't render the meshes of collidables, but the shapes""" 287 qr = self.world.query(HasModel, HasPose, exclude=[HasCollision]) 288 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 289 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 290 291 qr = self.world.query(HasModel, HasPose, HasCollision, exclude=[GroundFloor]) 292 collider_size = qr.collider_bbox[:, 1] - qr.collider_bbox[:, 0] 293 for pose, kind, radius, size, colliding in zip(qr.pose, qr.collider_kind, qr.collider_radii, 294 collider_size, qr.is_colliding): 295 color = rl.RED if colliding else rl.GREEN 296 if kind == ColliderKinds.SPHERE: 297 rl.DrawSphere(pose[0:3, 3].tolist(), radius.item(), color) 298 elif kind == ColliderKinds.AABB: 299 rl.DrawCube(pose[0:3, 3].tolist(), *size.tolist(), color) 300 else: 301 raise NotImplementedError(kind) 302 303 qr = self.world.query(HasModel, HasPose, GroundFloor) 304 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 305 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 306 307 for drawable in (extra_drawables or []): # e.g. plugins 308 drawable.draw(self.world, None) 309 310 def __repr__(self): 311 return f"[Simulator]\n{self.world}\n{self.plugins_manager}\n{self.connection_manager}\n{self.protocol}"
31class GroundFloor(Component): 32 """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
38def world_to_dict(world: World) -> dict[str, Any]: 39 """Serialize the world. Goes through all the entities and their components and converts the serializables to dict""" 40 res = {"entities": [], "components": world.component_names, "extra_metadata": world.extra_metadata} 41 for eid in world.live_entities.keys(): 42 res["entities"].append({"entity_id": eid, **world.get_entity(eid).to_dict(serialization_field="serializable")}) 43 return res
Serialize the world. Goes through all the entities and their components and converts the serializables to dict
45def world_from_dict(data: dict[str, Any]) -> World: 46 """Creates a world from a serialized representation e.g. from world_to_dict()""" 47 components = [{c.__name__: c for c in ALL_COMPONENTS}[c] for c in data["components"]] 48 world = World(components=components, extra_metadata=data["extra_metadata"]) 49 for entity in data["entities"]: 50 components = [world.component_name_to_type[c] for c in entity["components"]] 51 add_entity(world, components, **entity["data"]) 52 return world
Creates a world from a serialized representation e.g. from world_to_dict()
56@dataclass(kw_only=True) 57class SimState(Serializable): 58 """The state of the scene including the cameras, robot and so on""" 59 world_camera: Camera | None = None 60 topdown_camera: Camera | None = None 61 active_camera_type: str = "world" # Which camera is active based on it's type 62 fpv_ix: int = 0 63 wireframe_mode: bool = False 64 collision_render_mode: bool = False 65 collision_cell_size: Point3D = field(default_factory=lambda: make_arr(30, 30, 30)) 66 display_uav_trace: bool = False 67 uav_traces: dict[EntityId, FixedSizeDict[tuple[float, float, float], bool]] = field(default_factory=dict) 68 uav_traces_max_len: int = 1000 69 70 def __post_init__(self): 71 if self.world_camera is None: 72 self.world_camera = Camera(make_arr(15, 12, 15), make_arr(0, 2, 0), make_arr(0, 1, 0), 73 fovy=45, projection=rl.CAMERA_PERSPECTIVE) 74 if self.topdown_camera is None: 75 self.topdown_camera = Camera(make_arr(0, 30, 0), make_arr(0, 0, 0), make_arr(0, 0, -1), 76 fovy=20, projection=rl.CAMERA_ORTHOGRAPHIC) 77 78 @property 79 def active_camera_label(self) -> str: 80 """returns a string representation of the active camera for HUD""" 81 match self.active_camera_type: 82 case "world": return "WORLD CAMERA (F1)" 83 case "topdown": return "TOP-DOWN CAMERA (F2)" 84 case "fpv": return "FPV CAMERA (F3)" 85 case _: raise ValueError(self.active_camera_type) 86 87 @overrides 88 def to_dict(self) -> dict: 89 return { 90 "world_camera": self.world_camera.to_dict(), 91 "topdown_camera": self.topdown_camera.to_dict(), 92 "active_camera_type": self.active_camera_type, 93 "fpv_ix": self.fpv_ix, 94 "wireframe_mode": self.wireframe_mode, 95 "collision_render_mode": self.collision_render_mode, 96 "collision_cell_size": self.collision_cell_size.tolist(), 97 "display_uav_trace": self.display_uav_trace, 98 "uav_traces": FixedSizeDict({k: list(v.keys()) for k, v in self.uav_traces.items()}, 99 maxlen=self.uav_traces_max_len), 100 "uav_traces_max_len": self.uav_traces_max_len, 101 } 102 103 @staticmethod 104 @overrides 105 def from_dict(state: dict) -> SimState: 106 return SimState( 107 world_camera=Camera.from_dict(state["world_camera"]), 108 topdown_camera=Camera.from_dict(state["topdown_camera"]), 109 active_camera_type=state["active_camera_type"], 110 fpv_ix=state["fpv_ix"], 111 wireframe_mode=state["wireframe_mode"], 112 collision_render_mode=state["collision_render_mode"], 113 collision_cell_size=np.float32(state["collision_cell_size"]), 114 display_uav_trace=state["display_uav_trace"], 115 uav_traces={int(eid): FixedSizeDict({tuple(pt): True for pt in points}, maxlen=state["uav_traces_max_len"]) 116 for eid, points in state["uav_traces"].items()}, 117 uav_traces_max_len=state["uav_traces_max_len"], 118 )
The state of the scene including the cameras, robot and so on
78 @property 79 def active_camera_label(self) -> str: 80 """returns a string representation of the active camera for HUD""" 81 match self.active_camera_type: 82 case "world": return "WORLD CAMERA (F1)" 83 case "topdown": return "TOP-DOWN CAMERA (F2)" 84 case "fpv": return "FPV CAMERA (F3)" 85 case _: raise ValueError(self.active_camera_type)
returns a string representation of the active camera for HUD
87 @overrides 88 def to_dict(self) -> dict: 89 return { 90 "world_camera": self.world_camera.to_dict(), 91 "topdown_camera": self.topdown_camera.to_dict(), 92 "active_camera_type": self.active_camera_type, 93 "fpv_ix": self.fpv_ix, 94 "wireframe_mode": self.wireframe_mode, 95 "collision_render_mode": self.collision_render_mode, 96 "collision_cell_size": self.collision_cell_size.tolist(), 97 "display_uav_trace": self.display_uav_trace, 98 "uav_traces": FixedSizeDict({k: list(v.keys()) for k, v in self.uav_traces.items()}, 99 maxlen=self.uav_traces_max_len), 100 "uav_traces_max_len": self.uav_traces_max_len, 101 }
the dict representation of this object for serialization purposes
103 @staticmethod 104 @overrides 105 def from_dict(state: dict) -> SimState: 106 return SimState( 107 world_camera=Camera.from_dict(state["world_camera"]), 108 topdown_camera=Camera.from_dict(state["topdown_camera"]), 109 active_camera_type=state["active_camera_type"], 110 fpv_ix=state["fpv_ix"], 111 wireframe_mode=state["wireframe_mode"], 112 collision_render_mode=state["collision_render_mode"], 113 collision_cell_size=np.float32(state["collision_cell_size"]), 114 display_uav_trace=state["display_uav_trace"], 115 uav_traces={int(eid): FixedSizeDict({tuple(pt): True for pt in points}, maxlen=state["uav_traces_max_len"]) 116 for eid, points in state["uav_traces"].items()}, 117 uav_traces_max_len=state["uav_traces_max_len"], 118 )
loads this object from a serialized dict representation
120class Simulator(Restorable): 121 """The simulator object. Contains the scene (objects/robots), state and events. Wires to/from_dict as well.""" 122 def __init__(self, world: World, state: SimState, connection_manager: ConnectionManager, 123 plugins: list[str], protocol: Protocol): 124 self.world = world 125 self.state = state 126 self.connection_manager = connection_manager 127 self.plugins = plugins 128 self.protocol = protocol 129 130 self.plugins_manager = PluginsManager(registered_plugins=REGISTERED_PLUGINS, plugin_names=plugins) 131 self.physics_ticks_stats = deque(maxlen=TICK_STATS_LEN) 132 self.render_ticks_stats = deque(maxlen=TICK_STATS_LEN) 133 134 self.world.update() 135 self.robot_eids: list[EntityId] = self.world.query(HasFPV).entity_ids.tolist() 136 self.robot_eid_to_channel_ix: dict[EntityId, int] = {} 137 self._add_fpv_to_robots() 138 139 self._all_commands: set[str] | None = None # plugins + core commands cache 140 141 @property 142 def active_camera(self) -> Camera: 143 """The current active camera out of the 3: world, uav or topdown""" 144 match self.state.active_camera_type: 145 case "world": return self.state.world_camera 146 case "topdown": return self.state.topdown_camera 147 case "fpv": return self.world.get_entity( 148 self.world.query(HasFPV).entity_ids[self.state.fpv_ix]).fpv_camera[0] 149 case _: raise ValueError(self.state.active_camera_type) 150 151 @property 152 def all_commands(self) -> set[str]: 153 """All the commands (core and plugins) available in the simulator. Must be updated on plugins/world changes""" 154 if self._all_commands is None: 155 self._all_commands = set(self.protocol.endpoints) | self.plugins_manager.all_endpoints 156 return self._all_commands 157 158 def render_main_camera(self, extra_drawables: list[Drawable] | None): 159 """renders the main camera (the raylib UI) of the simulator""" 160 rl.BeginDrawing() 161 rl.ClearBackground(DEFAULT_BACKGROUND) 162 163 rl.BeginMode3D(self.active_camera.camera[0]) 164 165 if self.state.wireframe_mode: 166 rl.rlEnableWireMode() 167 168 if self.state.collision_render_mode: 169 self._render_collision_mode(extra_drawables) 170 else: 171 entity_id = self.robot_eids[self.state.fpv_ix] if self.state.active_camera_type == "fpv" else None 172 self._render(entity_id=entity_id, extra_drawables=extra_drawables) 173 174 if self.state.display_uav_trace: 175 for traces in self.state.uav_traces.values(): 176 for trace in traces.keys(): 177 rl.DrawSphere(trace, 0.05, rl.GREEN) 178 179 rl.EndMode3D() 180 181 if self.state.wireframe_mode: 182 rl.rlDisableWireMode() 183 184 n_robots = len(self.world.query(HasFPV)) 185 msg = f"{self.state.active_camera_label}\nObjects: {len(self.world)} (robots: {n_robots})" 186 rl.DrawText(msg.encode(), 10, 10, 20, rl.DARKGRAY) 187 rl.DrawFPS(rl.GetScreenWidth() - 80, 10) 188 rl.EndDrawing() 189 190 def render_fpv_cameras(self, extra_drawables: list[Drawable] | None): 191 """Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.""" 192 for robot_eid in self.robot_eids: 193 if robot_eid not in self.robot_eid_to_channel_ix: 194 continue 195 entity = self.world.get_entity(robot_eid) 196 fpv_texture: "rl.RenderTexture" = entity.fpv_texture.item() 197 fpv_data: FPVData = entity.fpv_data.item() 198 camera: Camera = entity.fpv_camera.item() 199 200 rl.BeginTextureMode(fpv_texture) 201 rl.ClearBackground(DEFAULT_BACKGROUND) 202 rl.BeginMode3D(camera.camera[0]) 203 self._render(entity_id=robot_eid, extra_drawables=extra_drawables) 204 rl.EndMode3D() 205 rl.EndTextureMode() 206 207 fpv_img = rl.LoadImageFromTexture(fpv_texture.texture) 208 fpv_img_ptr = rl.ffi.new("Image *", fpv_img) 209 rl.ImageFlipVertical(fpv_img_ptr) 210 with fpv_data.lock: 211 fpv_data.frame = bytes(rl.ffi.buffer(fpv_img_ptr.data, len(fpv_data.frame))) 212 fpv_data.frame_id += 1 213 fpv_data._frame_compressed = None # pylint: disable=protected-access 214 rl.UnloadImage(fpv_img_ptr[0]) 215 216 def assign_channel_to_first_free_robot(self, channel_idx: int) -> int | None: 217 """Gets the first free robot eid. Called from protocol._handle_message (slow). TODO: Use a lock ?""" 218 for robot_ix, robot_eid in enumerate(self.robot_eids): 219 if robot_eid not in self.robot_eid_to_channel_ix: 220 self.robot_eid_to_channel_ix[robot_eid] = channel_idx 221 return robot_ix 222 return None 223 224 def client_on_disconnect(self, client: RobosimClient): 225 """Clears the robot that was assigned to this connected client. TODO: use a lock ?""" 226 if client.state == RobosimClientState.CONNECTED: 227 robot_ix = self.robot_eids.index(client.robot_eid) 228 logger.debug(f"Releasing robot {robot_ix} (eid: {client.robot_eid}, channel: {client.channel.idx})") 229 self.robot_eid_to_channel_ix.pop(client.robot_eid) 230 client.state = RobosimClientState.STAGED 231 232 @overrides 233 def to_dict(self) -> dict: 234 return { 235 "world": world_to_dict(self.world), 236 "state": self.state.to_dict(), 237 "plugins": self.plugins_manager.to_dict(), 238 } 239 240 @overrides 241 def load_state_dict(self, state: dict): 242 state = deepcopy(state) # this is mostly for INIT_STATE as the code below mutates it while loading 243 244 self._load_world_from_dict(state["world"]) 245 self.state = SimState.from_dict(state["state"]) 246 247 old_names = set(self.plugins) 248 for dropped in old_names - state["plugins"].keys(): 249 logger.warning(f"Plugin '{dropped}' was live but absent from loaded state. Dropping.") 250 self.plugins = list(state["plugins"]) 251 self.plugins_manager = PluginsManager.from_dict(REGISTERED_PLUGINS, state["plugins"]) 252 self._all_commands = None # Reset this so all_commands is created again and we don't re-use the cache. 253 254 def _add_fpv_to_robots(self): 255 for eid in self.robot_eids: # FPV data is not part of ECS 256 camera = Camera(make_arr(0, 0, 0), make_arr(0, 0, 0), make_arr(0, 0, 0), 60.0, rl.CAMERA_PERSPECTIVE) 257 fpv_data = FPVData(frame=np.zeros((FPV_HEIGHT * FPV_WIDTH * 4,), "uint8"), 258 frame_shape=(FPV_HEIGHT, FPV_WIDTH, 4)) 259 robot = self.world.get_entity(eid) 260 # TODO(microecs-30): set_data(fpv_camera=fpv_camera) and auto-convert on microecs side. 261 robot.set_data(fpv_camera=np.array([camera], "object")) 262 robot.set_data(fpv_texture=np.array([rl.LoadRenderTexture(FPV_WIDTH, FPV_HEIGHT)], "object")) # TODO: dealoc 263 robot.set_data(fpv_data=np.array([fpv_data], "object")) 264 self.world.update() 265 266 def _load_world_from_dict(self, world_state: dict): 267 self.world = world_from_dict(world_state) 268 self.world.update() 269 270 self.robot_eids = self.world.query(HasFPV).entity_ids.tolist() 271 self._add_fpv_to_robots() 272 273 logger.info(f"Loaded world from state: {self.world}") 274 275 def _render(self, entity_id: int | None, extra_drawables: list[Drawable] | None): #noqa 276 """Renders the scene and all its objects + robots. Called from within a rl.BeginMode3D() block.""" 277 qr = self.world.query(HasModel, HasPose) 278 for i, (model, pose, scale) in enumerate(zip(qr.model, qr.pose, qr.scale)): 279 if entity_id is not None and entity_id == qr.entity_ids[i]: # if in FPV mode then don't draw yourself 280 continue 281 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 282 283 for drawable in (extra_drawables or []): # e.g. plugins 284 drawable.draw(self.world, entity_id) 285 286 def _render_collision_mode(self, extra_drawables: list[Drawable] | None): 287 """render the scene in 'collision mode' which means we don't render the meshes of collidables, but the shapes""" 288 qr = self.world.query(HasModel, HasPose, exclude=[HasCollision]) 289 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 290 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 291 292 qr = self.world.query(HasModel, HasPose, HasCollision, exclude=[GroundFloor]) 293 collider_size = qr.collider_bbox[:, 1] - qr.collider_bbox[:, 0] 294 for pose, kind, radius, size, colliding in zip(qr.pose, qr.collider_kind, qr.collider_radii, 295 collider_size, qr.is_colliding): 296 color = rl.RED if colliding else rl.GREEN 297 if kind == ColliderKinds.SPHERE: 298 rl.DrawSphere(pose[0:3, 3].tolist(), radius.item(), color) 299 elif kind == ColliderKinds.AABB: 300 rl.DrawCube(pose[0:3, 3].tolist(), *size.tolist(), color) 301 else: 302 raise NotImplementedError(kind) 303 304 qr = self.world.query(HasModel, HasPose, GroundFloor) 305 for model, pose, scale in zip(qr.model, qr.pose, qr.scale): 306 rl.DrawModel(model.item().model, pose[0:3, 3].tolist(), scale.item(), rl.WHITE) 307 308 for drawable in (extra_drawables or []): # e.g. plugins 309 drawable.draw(self.world, None) 310 311 def __repr__(self): 312 return f"[Simulator]\n{self.world}\n{self.plugins_manager}\n{self.connection_manager}\n{self.protocol}"
The simulator object. Contains the scene (objects/robots), state and events. Wires to/from_dict as well.
122 def __init__(self, world: World, state: SimState, connection_manager: ConnectionManager, 123 plugins: list[str], protocol: Protocol): 124 self.world = world 125 self.state = state 126 self.connection_manager = connection_manager 127 self.plugins = plugins 128 self.protocol = protocol 129 130 self.plugins_manager = PluginsManager(registered_plugins=REGISTERED_PLUGINS, plugin_names=plugins) 131 self.physics_ticks_stats = deque(maxlen=TICK_STATS_LEN) 132 self.render_ticks_stats = deque(maxlen=TICK_STATS_LEN) 133 134 self.world.update() 135 self.robot_eids: list[EntityId] = self.world.query(HasFPV).entity_ids.tolist() 136 self.robot_eid_to_channel_ix: dict[EntityId, int] = {} 137 self._add_fpv_to_robots() 138 139 self._all_commands: set[str] | None = None # plugins + core commands cache
141 @property 142 def active_camera(self) -> Camera: 143 """The current active camera out of the 3: world, uav or topdown""" 144 match self.state.active_camera_type: 145 case "world": return self.state.world_camera 146 case "topdown": return self.state.topdown_camera 147 case "fpv": return self.world.get_entity( 148 self.world.query(HasFPV).entity_ids[self.state.fpv_ix]).fpv_camera[0] 149 case _: raise ValueError(self.state.active_camera_type)
The current active camera out of the 3: world, uav or topdown
151 @property 152 def all_commands(self) -> set[str]: 153 """All the commands (core and plugins) available in the simulator. Must be updated on plugins/world changes""" 154 if self._all_commands is None: 155 self._all_commands = set(self.protocol.endpoints) | self.plugins_manager.all_endpoints 156 return self._all_commands
All the commands (core and plugins) available in the simulator. Must be updated on plugins/world changes
158 def render_main_camera(self, extra_drawables: list[Drawable] | None): 159 """renders the main camera (the raylib UI) of the simulator""" 160 rl.BeginDrawing() 161 rl.ClearBackground(DEFAULT_BACKGROUND) 162 163 rl.BeginMode3D(self.active_camera.camera[0]) 164 165 if self.state.wireframe_mode: 166 rl.rlEnableWireMode() 167 168 if self.state.collision_render_mode: 169 self._render_collision_mode(extra_drawables) 170 else: 171 entity_id = self.robot_eids[self.state.fpv_ix] if self.state.active_camera_type == "fpv" else None 172 self._render(entity_id=entity_id, extra_drawables=extra_drawables) 173 174 if self.state.display_uav_trace: 175 for traces in self.state.uav_traces.values(): 176 for trace in traces.keys(): 177 rl.DrawSphere(trace, 0.05, rl.GREEN) 178 179 rl.EndMode3D() 180 181 if self.state.wireframe_mode: 182 rl.rlDisableWireMode() 183 184 n_robots = len(self.world.query(HasFPV)) 185 msg = f"{self.state.active_camera_label}\nObjects: {len(self.world)} (robots: {n_robots})" 186 rl.DrawText(msg.encode(), 10, 10, 20, rl.DARKGRAY) 187 rl.DrawFPS(rl.GetScreenWidth() - 80, 10) 188 rl.EndDrawing()
renders the main camera (the raylib UI) of the simulator
190 def render_fpv_cameras(self, extra_drawables: list[Drawable] | None): 191 """Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.""" 192 for robot_eid in self.robot_eids: 193 if robot_eid not in self.robot_eid_to_channel_ix: 194 continue 195 entity = self.world.get_entity(robot_eid) 196 fpv_texture: "rl.RenderTexture" = entity.fpv_texture.item() 197 fpv_data: FPVData = entity.fpv_data.item() 198 camera: Camera = entity.fpv_camera.item() 199 200 rl.BeginTextureMode(fpv_texture) 201 rl.ClearBackground(DEFAULT_BACKGROUND) 202 rl.BeginMode3D(camera.camera[0]) 203 self._render(entity_id=robot_eid, extra_drawables=extra_drawables) 204 rl.EndMode3D() 205 rl.EndTextureMode() 206 207 fpv_img = rl.LoadImageFromTexture(fpv_texture.texture) 208 fpv_img_ptr = rl.ffi.new("Image *", fpv_img) 209 rl.ImageFlipVertical(fpv_img_ptr) 210 with fpv_data.lock: 211 fpv_data.frame = bytes(rl.ffi.buffer(fpv_img_ptr.data, len(fpv_data.frame))) 212 fpv_data.frame_id += 1 213 fpv_data._frame_compressed = None # pylint: disable=protected-access 214 rl.UnloadImage(fpv_img_ptr[0])
Renders each robot's FPV camera but only if it's streaming. Calls self.draw() for scene content.
216 def assign_channel_to_first_free_robot(self, channel_idx: int) -> int | None: 217 """Gets the first free robot eid. Called from protocol._handle_message (slow). TODO: Use a lock ?""" 218 for robot_ix, robot_eid in enumerate(self.robot_eids): 219 if robot_eid not in self.robot_eid_to_channel_ix: 220 self.robot_eid_to_channel_ix[robot_eid] = channel_idx 221 return robot_ix 222 return None
Gets the first free robot eid. Called from protocol._handle_message (slow). TODO: Use a lock ?
224 def client_on_disconnect(self, client: RobosimClient): 225 """Clears the robot that was assigned to this connected client. TODO: use a lock ?""" 226 if client.state == RobosimClientState.CONNECTED: 227 robot_ix = self.robot_eids.index(client.robot_eid) 228 logger.debug(f"Releasing robot {robot_ix} (eid: {client.robot_eid}, channel: {client.channel.idx})") 229 self.robot_eid_to_channel_ix.pop(client.robot_eid) 230 client.state = RobosimClientState.STAGED
Clears the robot that was assigned to this connected client. TODO: use a lock ?
232 @overrides 233 def to_dict(self) -> dict: 234 return { 235 "world": world_to_dict(self.world), 236 "state": self.state.to_dict(), 237 "plugins": self.plugins_manager.to_dict(), 238 }
the dict representation of this object for serialization purposes
240 @overrides 241 def load_state_dict(self, state: dict): 242 state = deepcopy(state) # this is mostly for INIT_STATE as the code below mutates it while loading 243 244 self._load_world_from_dict(state["world"]) 245 self.state = SimState.from_dict(state["state"]) 246 247 old_names = set(self.plugins) 248 for dropped in old_names - state["plugins"].keys(): 249 logger.warning(f"Plugin '{dropped}' was live but absent from loaded state. Dropping.") 250 self.plugins = list(state["plugins"]) 251 self.plugins_manager = PluginsManager.from_dict(REGISTERED_PLUGINS, state["plugins"]) 252 self._all_commands = None # Reset this so all_commands is created again and we don't re-use the cache.
Loads in place this object from a serialized dict representation