diff --git a/README.md b/README.md index e69de29..afa360a 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,142 @@ +# Add Camera System + +A flexible camera system for a 2D renderer architecture built with Python and Matplotlib. + +--- +## students: Nagham Sleman 476937 Salam Ali 503263 + +## Features + +* Top view projection +* Left view projection +* Smooth camera following +* Camera zoom in/out +* Camera panning +* World-to-screen transformation +* Screen-to-world transformation +* Dynamic grid rendering +* Modular renderer architecture + +--- + +## Controls + +| Key | Action | +| ----------- | -------------------- | +| Mouse Wheel | Zoom in/out | +| Arrow Keys | Pan camera | +| F | Toggle camera follow | +| V | Switch camera view | + +--- + +## Project Structure + +```text +camera-system/ +│ +├── camera.py +├── renderer.py +├── robot.py +├── world.py +│ +├── docs/ +│ ├── ticket20.jpg +│ └── ticket20.gif +│ +├── main.py +├── README.md +└── requirements.txt +``` + +--- + +## Architecture Diagram + +![Ticket 20](src/simulator/ticket20.jpg) + +## Demo GIF + +![Ticket 20 GIF](src/simulator/ticket20.gif) + +## Coordinate Systems + +### World Space + +3D coordinates inside the simulation world. + +**Example:** + +```text +(100, 50, 20) +``` + +### Screen Space + +2D coordinates rendered on the screen. + +**Example:** + +```text +(500, 300) +``` + +--- + +## Projection Modes + +### Top View + +Projects: + +```text +(X, Y) +``` + +The Z-axis is hidden. + +### Left View + +Projects: + +```text +(X, Z) +``` + +The Y-axis is hidden. + +--- + +## Camera Features + +* Object following +* Smooth movement +* Zoom scaling +* Coordinate transformations +* Camera panning +* Dynamic grid rendering +* View switching + +--- + +## Installation + +```bash +pip install -r requirements.txt +``` + +--- + +## Run + +```bash +python main.py +``` + +--- + +## Requirements + +* Python 3.10+ +* NumPy +* Matplotlib diff --git a/src/simulator/camera.py b/src/simulator/camera.py new file mode 100644 index 0000000..3f2fe93 --- /dev/null +++ b/src/simulator/camera.py @@ -0,0 +1,68 @@ +from enum import Enum + + +class ViewMode(Enum): + TOP = "top" + LEFT = "left" + + +class Camera: + def init(self, x_limits=(-10.0, 10.0), y_limits=(-10.0, 10.0)): + self.position = [0.0, 0.0, 0.0] + self.zoom = 1.0 + self.view_mode = ViewMode.TOP + self.target = None + self.follow_smoothing = 0.25 + + self.base_width = x_limits[1] - x_limits[0] + self.base_height = y_limits[1] - y_limits[0] + + def toggle_view_mode(self): + self.view_mode = ViewMode.LEFT if self.view_mode == ViewMode.TOP else ViewMode.TOP + + def follow(self, target): + self.target = target + + def update(self): + if self.target is None: + return + + target_pos = self.target.get_position() + + for i in range(3): + self.position[i] += (target_pos[i] - self.position[i]) * self.follow_smoothing + + def zoom_in(self): + self.zoom = min(self.zoom * 1.25, 10.0) + + def zoom_out(self): + self.zoom = max(self.zoom / 1.25, 0.2) + + def pan(self, dx, dy): + dx /= self.zoom + dy /= self.zoom + + if self.view_mode == ViewMode.TOP: + self.position[0] += dx + self.position[1] += dy + else: + self.position[0] += dx + self.position[2] += dy + + def project(self, position): + x, y, z = position + + if self.view_mode == ViewMode.TOP: + return x, y + + return x, z + + def apply_to_axes(self, ax): + cx, cy = self.project(self.position) + + half_width = self.base_width / (2.0 * self.zoom) + half_height = self.base_height / (2.0 * self.zoom) + + ax.set_xlim(cx - half_width, cx + half_width) + ax.set_ylim(cy - half_height, cy + half_height) + ax.set_aspect("equal", adjustable="box") \ No newline at end of file diff --git a/src/simulator/main.py b/src/simulator/main.py index bd19be6..9a1063b 100644 --- a/src/simulator/main.py +++ b/src/simulator/main.py @@ -1,34 +1,25 @@ -import sys -import numpy as np +import time +import matplotlib.pyplot as plt -from physics import PhysicsEngine from renderer import Renderer from world import World -from objects import RobotTree, TwoLink, Tree7, CartPole -from dynamics.ab_algorithm import ABAlgorithm - def main(): - - fd_solver = ABAlgorithm() - - physics = PhysicsEngine(fd_solver, gravity=[0.0, -9.81, 0.0]) renderer = Renderer() + world = World() - world = World(physics, renderer) - - # robot = TwoLink() - # robot = Tree7() - - # robot = RobotTree() - # robot.some_tree(8,2) - - robot = CartPole() + try: + while plt.fignum_exists(renderer.fig.number): + world.update() + renderer.update(world.get_objects()) + time.sleep(0.02) - world.add_object(robot) + except KeyboardInterrupt: + pass - world.run(1000) + finally: + renderer.close() if __name__ == "__main__": diff --git a/src/simulator/renderer.py b/src/simulator/renderer.py index e7c4820..afeaccd 100644 --- a/src/simulator/renderer.py +++ b/src/simulator/renderer.py @@ -1,104 +1,335 @@ import numpy as np - import matplotlib.pyplot as plt -from matplotlib.patches import Circle, Rectangle from matplotlib.collections import LineCollection +from camera import Camera, ViewMode + class Renderer: - def __init__(self, x_limits=(-10.0, 10.0), y_limits=(-10.0, 10.0), max_colors=20) -> None: - self.fig = plt.figure() - self.ax = self.fig.add_subplot(111, aspect="equal") - self.ax.set_xlim(x_limits) - self.ax.set_ylim(y_limits) - self.ax.set_aspect("equal") - - # world frame - self.ax.annotate( - "", - xy=(x_limits[1] / 10, 0), - xytext=(0, 0), - arrowprops=dict(arrowstyle="->", color="red", alpha=0.5), + + def __init__( + self, + x_limits=(-10.0, 10.0), + y_limits=(-10.0, 10.0), + max_colors=20 + ): + + self.fig = plt.figure(figsize=(10, 8)) + + self.ax = self.fig.add_subplot( + 111, + aspect="equal" ) - self.ax.annotate( - "", - xy=(0, y_limits[1] / 10), - xytext=(0, 0), - arrowprops=dict(arrowstyle="->", color="green", alpha=0.5), + + self.camera = Camera( + x_limits=x_limits, + y_limits=y_limits + ) + + self.follow_enabled = True + + self.camera.apply_to_axes(self.ax) + + self.fig.canvas.mpl_connect( + "key_press_event", + self._on_key_press + ) + + self.fig.canvas.mpl_connect( + "scroll_event", + self._on_scroll ) plt.show(block=False) self.links_lines = None self.joints_circles = None - self.colors = plt.cm.rainbow(np.linspace(0, 1, max_colors)) + self.base_point = None - self.base_patch = None + self.colors = plt.cm.rainbow( + np.linspace(0, 1, max_colors) + ) - def update(self, objects, dt=0.0001): + def _on_scroll(self, event): - if not objects: - return + if event.button == "up": + self.camera.zoom_in() + + elif event.button == "down": + self.camera.zoom_out() + + self.camera.apply_to_axes(self.ax) + + self.fig.canvas.draw() + self.fig.canvas.flush_events() + + def _on_key_press(self, event): + + pan_step = 2.0 + + if event.key in ["v", "V"]: + self.camera.toggle_view_mode() + + elif event.key in ["f", "F"]: + self.follow_enabled = not self.follow_enabled + + elif event.key == "left": + self.follow_enabled = False + self.camera.pan(-pan_step, 0.0) + + elif event.key == "right": + self.follow_enabled = False + self.camera.pan(pan_step, 0.0) + + elif event.key == "up": + self.follow_enabled = False + self.camera.pan(0.0, pan_step) + + elif event.key == "down": + self.follow_enabled = False + self.camera.pan(0.0, -pan_step) + + self.camera.apply_to_axes(self.ax) + + self.fig.canvas.draw() + self.fig.canvas.flush_events() + + def _draw_grid(self): + + xmin, xmax = self.ax.get_xlim() + ymin, ymax = self.ax.get_ylim() + + step = 1.0 + + x_values = np.arange( + np.floor(xmin), + np.ceil(xmax) + step, + step + ) + + y_values = np.arange( + np.floor(ymin), + np.ceil(ymax) + step, + step + ) + + for x in x_values: + self.ax.axvline( + x, + color="gray", + alpha=0.15, + linewidth=0.5 + ) + + for y in y_values: + self.ax.axhline( + y, + color="gray", + alpha=0.15, + linewidth=0.5 + ) + + def _draw_world_frame(self): - all_links = [] - all_points = [] + axis_label_offset = 0.5 - def draw_tree(obj, q): - parents = obj.model["parent"] - nodes = [] - edges = [] - angles = [0.0] * len(parents) + if self.camera.view_mode == ViewMode.TOP: + # TOP view shows X-Y projection. + # Z axis is hidden. + self.ax.axhline( + 0.0, + color="green", + alpha=0.5, + linewidth=1.5 + ) - length = 1.0 + self.ax.axvline( + 0.0, + color="red", + alpha=0.5, + linewidth=1.5 + ) - for i in range(len(parents)): - parent = parents[i] + self.ax.text( + self.ax.get_xlim()[1] - axis_label_offset, + axis_label_offset, + "X", + color="red" + ) - if parent == -1: - x_p, y_p = 0.0, 0.0 - angles[i] = q[i] - else: - x_p, y_p = nodes[parent] - angles[i] = angles[parent] + q[i] + self.ax.text( + axis_label_offset, + self.ax.get_ylim()[1] - axis_label_offset, + "Y", + color="green" + ) - x_child = x_p + length * np.cos(angles[i]) + else: + # LEFT view shows X-Z projection. + # Y axis is hidden. + self.ax.axhline( + 0.0, + color="blue", + alpha=0.5, + linewidth=1.5 + ) + + self.ax.axvline( + 0.0, + color="red", + alpha=0.5, + linewidth=1.5 + ) + + self.ax.text( + self.ax.get_xlim()[1] - axis_label_offset, + axis_label_offset, + "X", + color="red" + ) + + self.ax.text( + axis_label_offset, + self.ax.get_ylim()[1] - axis_label_offset, + "Z", + color="blue" + ) + + def _draw_tree(self, obj, q): + + parents = obj.model["parent"] + + nodes = [] + edges = [] + angles = [0.0] * len(parents) + + length = 1.0 + + base_x, base_y = self.camera.project( + obj.get_position() + ) + + for i in range(len(parents)): + + parent = parents[i] + + if parent == -1: + x_p, y_p = base_x, base_y + angles[i] = q[i] + + else: + x_p, y_p = nodes[parent] + angles[i] = angles[parent] + q[i] + + x_child = x_p + length * np.cos(angles[i]) + + if self.camera.view_mode == ViewMode.TOP: y_child = y_p + length * np.sin(angles[i]) + else: + y_child = y_p + 0.5 * np.sin(q[i]) - nodes.append((x_child, y_child)) - edges.append((x_p, y_p, x_child, y_child)) + nodes.append( + ( + x_child, + y_child + ) + ) - return nodes, edges + edges.append( + ( + x_p, + y_p, + x_child, + y_child + ) + ) + + return nodes, edges + + def update(self, objects, dt=0.0001): + + if not objects: + return + + self.ax.cla() + + if ( + self.follow_enabled + and self.camera.target is not None + ): + self.camera.update() + + self.camera.apply_to_axes(self.ax) + + self._draw_grid() + self._draw_world_frame() for obj in objects: - links = [] - points = [] - nodes, edges = draw_tree(obj, obj.q) + if self.camera.target is None: + self.camera.follow(obj) + + nodes, edges = self._draw_tree( + obj, + obj.q + ) - for edge in edges: - x_p, y_p, x_c, y_c = edge - links.append([[x_p, y_p], [x_c, y_c]]) + links = [] + + for x_p, y_p, x_c, y_c in edges: + links.append( + [ + [x_p, y_p], + [x_c, y_c] + ] + ) links = np.array(links) points = np.array(nodes) - all_links.append(links) - all_points.append(points) + self.links_lines = LineCollection( + links, + colors=self.colors[:len(links)], + linewidths=3 + ) - if self.links_lines is None: - self.links_lines = LineCollection(links, colors=self.colors, linewidths=3) - self.ax.add_collection(self.links_lines) + self.ax.add_collection( + self.links_lines + ) - self.joints_circles = self.ax.scatter( - points[:, 0], points[:, 1], c="lightblue", s=40, zorder=10 - ) - self.ax.scatter(0.0, 0.0, c="blue", s=50, zorder=10) - else: - self.links_lines.set_segments(links) - self.joints_circles.set_offsets(points) + self.joints_circles = self.ax.scatter( + points[:, 0], + points[:, 1], + c="lightblue", + s=50, + zorder=10 + ) - self.fig.canvas.draw_idle() + base_x, base_y = self.camera.project( + obj.get_position() + ) + + self.base_point = self.ax.scatter( + base_x, + base_y, + c="blue", + s=70, + zorder=10 + ) + + self.ax.set_title( + f"View: {self.camera.view_mode.name} | " + f"Zoom: {self.camera.zoom:.2f} | " + f"Follow: {self.follow_enabled}\n" + f"Mouse Wheel = Zoom | " + f"V = View | " + f"F = Follow | " + f"Arrows = Pan" + ) + + self.fig.canvas.draw() self.fig.canvas.flush_events() def close(self): + plt.close(self.fig) diff --git a/src/simulator/robot.py b/src/simulator/robot.py new file mode 100644 index 0000000..c4e7be3 --- /dev/null +++ b/src/simulator/robot.py @@ -0,0 +1,32 @@ +import numpy as np + + +class Robot: + def init(self): + self.model = { + "parent": [-1, 0, 1, 2] + } + + self.q = np.array([ + 0.0, + 0.5, + -0.4, + 0.3 + ]) + + self.position = [ + 0.0, + 0.0, + 0.0 + ] + + def get_position(self): + return self.position + + def update(self): + self.q[0] += 0.02 + self.q[1] += 0.015 + self.q[2] -= 0.01 + self.q[3] += 0.008 + + self.position[0] += 0.15 \ No newline at end of file diff --git a/src/simulator/ticket20.gif b/src/simulator/ticket20.gif new file mode 100644 index 0000000..58a9118 Binary files /dev/null and b/src/simulator/ticket20.gif differ diff --git a/src/simulator/ticket20.jpg b/src/simulator/ticket20.jpg new file mode 100644 index 0000000..fb507b3 Binary files /dev/null and b/src/simulator/ticket20.jpg differ diff --git a/src/simulator/ticket20.mp4 b/src/simulator/ticket20.mp4 new file mode 100644 index 0000000..0a1517c Binary files /dev/null and b/src/simulator/ticket20.mp4 differ diff --git a/src/simulator/world.py b/src/simulator/world.py index 813c884..9f8bab7 100644 --- a/src/simulator/world.py +++ b/src/simulator/world.py @@ -1,27 +1,13 @@ -import time +from robot import Robot class World: - def __init__(self, physics, renderer): - self.physics = physics - self.renderer = renderer - self.objects = [] - self.time = 0 - self.dt = 0.02 + def init(self): + self.robot = Robot() + self.objects = [self.robot] - def set_plane(self, plane): - self.plane = plane + def update(self): + self.robot.update() - def add_object(self, obj): - self.objects.append(obj) - - def step(self, i): - self.physics.update(self.objects, self.dt) - if i % 2 == 0: - self.renderer.update(self.objects) - self.time += self.dt - - def run(self, steps): - for i in range(steps): - self.step(i) - time.sleep(self.dt) + def get_objects(self): + return self.objects