nav-sim2d 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: nav-sim2d
3
+ Version: 0.0.1
4
+ Summary: Educational library of 2D simulation for mobile robot navigation.
5
+ Author: Elias J. R. Freitas
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: pygame>=2.5.0
9
+ Requires-Dist: pygame_gui>=0.6.9
10
+ Requires-Dist: numpy>=1.24
11
+ Requires-Dist: PyYAML>=6.0
12
+
13
+ # Nav Sim
14
+
15
+ A lightweight 2D simulation framework for mobile robot navigation using **Pygame**, with support for sensors, external controllers, and interactive visualization.
16
+
17
+ ---
18
+
19
+ ## Supported Robot Models
20
+
21
+ - `holonomic`
22
+ Command:
23
+ ```python
24
+ {"vx": ..., "vy": ...}
25
+ ```
26
+
27
+ - `differential`
28
+ Command:
29
+ ```python
30
+ {"v": ..., "omega": ...}
31
+ ```
32
+
33
+ - `ackermann`
34
+ Command:
35
+ ```python
36
+ {"speed": ..., "steer": ...}
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 🧠 Architecture
42
+
43
+ The simulator follows a modular design:
44
+
45
+ - ✅ **Simulator (`nav_sim`)**
46
+ Handles physics, rendering, environment, and sensors
47
+
48
+ - ✅ **External Controllers**
49
+ Implement navigation strategies (APF, Vortex, etc.)
50
+
51
+ > Controllers are not part of the core library. They live in `examples/` or in the user’s project.
52
+
53
+ ---
54
+
55
+ ## 📡 Sensors
56
+
57
+ ### LiDAR (2D)
58
+
59
+ The simulator provides a configurable 2D LiDAR:
60
+
61
+ - Angular scan
62
+ - Range-limited
63
+ - Returns:
64
+
65
+ ```python
66
+ (angle, distance, hit_point)
67
+ ```
68
+
69
+ - Passed to controllers as:
70
+
71
+ ```python
72
+ lidar_readings
73
+ ```
74
+
75
+ ---
76
+
77
+ ## ⚙️ Installation
78
+
79
+ ```bash
80
+ python -m venv .venv
81
+
82
+ # Linux / macOS
83
+ source .venv/bin/activate
84
+
85
+ # Windows
86
+ .venv\Scripts\activate
87
+
88
+ pip install -r requirements.txt
89
+ ```
90
+
91
+ ---
92
+
93
+ ## ▶️ Run Examples
94
+
95
+ ```bash
96
+ python examples/run_holonomic.py
97
+ python examples/run_differential.py
98
+ python examples/run_ackermann.py
99
+ ```
100
+
101
+ ---
102
+
103
+ ## 🧪 Basic Usage
104
+
105
+ ```python
106
+ from pathlib import Path
107
+ from nav_sim import Simulator, load_config
108
+ from examples.controllers.potential_field import potential_field_controller
109
+
110
+ config = load_config(Path("config/default.yaml"))
111
+ config["robot"]["type"] = "differential"
112
+
113
+ sim = Simulator(
114
+ config=config,
115
+ controller=potential_field_controller
116
+ )
117
+
118
+ sim.run()
119
+ ```
120
+
121
+ ---
122
+
123
+ ## 🧩 Controller Interface
124
+
125
+ ```python
126
+ def controller(robot, world, goal, config, dt, lidar_readings=None) -> dict:
127
+ ...
128
+ ```
129
+
130
+ ### Parameters
131
+
132
+ - `robot` → robot state (position, heading, limits)
133
+ - `world` → obstacle representation
134
+ - `goal` → current waypoint
135
+ - `config` → YAML configuration
136
+ - `dt` → simulation timestep
137
+ - `lidar_readings` → sensor data
138
+
139
+ ---
140
+
141
+ ## 🧠 Artificial Potential Fields (APF)
142
+
143
+ This project includes a classical APF implementation based on Khatib’s method:
144
+
145
+ ### Total force
146
+
147
+ F(q) = F_att(q) + F_rep(q)
148
+
149
+ ### Attractive force
150
+
151
+ F_att = k_att (q_goal - q)
152
+
153
+ ### Repulsive force
154
+
155
+ F_rep,i = k_r (1/d_i - 1/d_s) (1/d_i^2) r_hat_i
156
+
157
+ Where:
158
+
159
+ - d_i → LiDAR distance corrected by robot radius
160
+ - d_s → influence distance
161
+ - r_hat_i → unit vector away from obstacle
162
+
163
+ ---
164
+
165
+ ## 🛑 Collision Handling
166
+
167
+ The simulator detects collisions using:
168
+
169
+ d <= r_robot + r_obstacle
170
+
171
+ ### Behavior
172
+
173
+ - The robot stops immediately
174
+ - A visual indicator (💥) is displayed
175
+
176
+ ---
177
+
178
+ ## 🎨 Custom Drawing
179
+
180
+ You can extend visualization with:
181
+
182
+ ```python
183
+ def custom_draw(sim, screen):
184
+ ...
185
+ ```
186
+
187
+ Usage:
188
+
189
+ ```python
190
+ sim = Simulator(
191
+ config=config,
192
+ controller=controller,
193
+ draw_callback=custom_draw
194
+ )
195
+ ```
196
+
197
+ ---
198
+
199
+ ## 🖱️ User Interaction
200
+
201
+ - Left click → add waypoint
202
+ - Right click → add obstacle
203
+ - Scroll → zoom
204
+ - Middle button + drag → pan
205
+
206
+ ### GUI
207
+
208
+ - Center Camera → reset view
209
+ - Reset → restart simulation
210
+
211
+ ---
212
+
213
+ ## ✅ Features
214
+
215
+ - ✅ Multiple robot models
216
+ - ✅ 2D LiDAR sensor
217
+ - ✅ External controllers
218
+ - ✅ Artificial Potential Fields (APF)
219
+ - ✅ Collision detection with geometry
220
+ - ✅ Custom rendering
221
+ - ✅ Real-time interaction
222
+
223
+ ---
224
+
225
+ ## 🚀 Roadmap
226
+
227
+ - Vortex APF
228
+ - Harmonic (Laplace) fields
229
+ - Local minima avoidance
230
+ - Advanced visualization
231
+
232
+ ---
233
+
234
+ ## 📄 License
235
+
236
+ Free for educational and research use.
@@ -0,0 +1,224 @@
1
+ # Nav Sim
2
+
3
+ A lightweight 2D simulation framework for mobile robot navigation using **Pygame**, with support for sensors, external controllers, and interactive visualization.
4
+
5
+ ---
6
+
7
+ ## Supported Robot Models
8
+
9
+ - `holonomic`
10
+ Command:
11
+ ```python
12
+ {"vx": ..., "vy": ...}
13
+ ```
14
+
15
+ - `differential`
16
+ Command:
17
+ ```python
18
+ {"v": ..., "omega": ...}
19
+ ```
20
+
21
+ - `ackermann`
22
+ Command:
23
+ ```python
24
+ {"speed": ..., "steer": ...}
25
+ ```
26
+
27
+ ---
28
+
29
+ ## 🧠 Architecture
30
+
31
+ The simulator follows a modular design:
32
+
33
+ - ✅ **Simulator (`nav_sim`)**
34
+ Handles physics, rendering, environment, and sensors
35
+
36
+ - ✅ **External Controllers**
37
+ Implement navigation strategies (APF, Vortex, etc.)
38
+
39
+ > Controllers are not part of the core library. They live in `examples/` or in the user’s project.
40
+
41
+ ---
42
+
43
+ ## 📡 Sensors
44
+
45
+ ### LiDAR (2D)
46
+
47
+ The simulator provides a configurable 2D LiDAR:
48
+
49
+ - Angular scan
50
+ - Range-limited
51
+ - Returns:
52
+
53
+ ```python
54
+ (angle, distance, hit_point)
55
+ ```
56
+
57
+ - Passed to controllers as:
58
+
59
+ ```python
60
+ lidar_readings
61
+ ```
62
+
63
+ ---
64
+
65
+ ## ⚙️ Installation
66
+
67
+ ```bash
68
+ python -m venv .venv
69
+
70
+ # Linux / macOS
71
+ source .venv/bin/activate
72
+
73
+ # Windows
74
+ .venv\Scripts\activate
75
+
76
+ pip install -r requirements.txt
77
+ ```
78
+
79
+ ---
80
+
81
+ ## ▶️ Run Examples
82
+
83
+ ```bash
84
+ python examples/run_holonomic.py
85
+ python examples/run_differential.py
86
+ python examples/run_ackermann.py
87
+ ```
88
+
89
+ ---
90
+
91
+ ## 🧪 Basic Usage
92
+
93
+ ```python
94
+ from pathlib import Path
95
+ from nav_sim import Simulator, load_config
96
+ from examples.controllers.potential_field import potential_field_controller
97
+
98
+ config = load_config(Path("config/default.yaml"))
99
+ config["robot"]["type"] = "differential"
100
+
101
+ sim = Simulator(
102
+ config=config,
103
+ controller=potential_field_controller
104
+ )
105
+
106
+ sim.run()
107
+ ```
108
+
109
+ ---
110
+
111
+ ## 🧩 Controller Interface
112
+
113
+ ```python
114
+ def controller(robot, world, goal, config, dt, lidar_readings=None) -> dict:
115
+ ...
116
+ ```
117
+
118
+ ### Parameters
119
+
120
+ - `robot` → robot state (position, heading, limits)
121
+ - `world` → obstacle representation
122
+ - `goal` → current waypoint
123
+ - `config` → YAML configuration
124
+ - `dt` → simulation timestep
125
+ - `lidar_readings` → sensor data
126
+
127
+ ---
128
+
129
+ ## 🧠 Artificial Potential Fields (APF)
130
+
131
+ This project includes a classical APF implementation based on Khatib’s method:
132
+
133
+ ### Total force
134
+
135
+ F(q) = F_att(q) + F_rep(q)
136
+
137
+ ### Attractive force
138
+
139
+ F_att = k_att (q_goal - q)
140
+
141
+ ### Repulsive force
142
+
143
+ F_rep,i = k_r (1/d_i - 1/d_s) (1/d_i^2) r_hat_i
144
+
145
+ Where:
146
+
147
+ - d_i → LiDAR distance corrected by robot radius
148
+ - d_s → influence distance
149
+ - r_hat_i → unit vector away from obstacle
150
+
151
+ ---
152
+
153
+ ## 🛑 Collision Handling
154
+
155
+ The simulator detects collisions using:
156
+
157
+ d <= r_robot + r_obstacle
158
+
159
+ ### Behavior
160
+
161
+ - The robot stops immediately
162
+ - A visual indicator (💥) is displayed
163
+
164
+ ---
165
+
166
+ ## 🎨 Custom Drawing
167
+
168
+ You can extend visualization with:
169
+
170
+ ```python
171
+ def custom_draw(sim, screen):
172
+ ...
173
+ ```
174
+
175
+ Usage:
176
+
177
+ ```python
178
+ sim = Simulator(
179
+ config=config,
180
+ controller=controller,
181
+ draw_callback=custom_draw
182
+ )
183
+ ```
184
+
185
+ ---
186
+
187
+ ## 🖱️ User Interaction
188
+
189
+ - Left click → add waypoint
190
+ - Right click → add obstacle
191
+ - Scroll → zoom
192
+ - Middle button + drag → pan
193
+
194
+ ### GUI
195
+
196
+ - Center Camera → reset view
197
+ - Reset → restart simulation
198
+
199
+ ---
200
+
201
+ ## ✅ Features
202
+
203
+ - ✅ Multiple robot models
204
+ - ✅ 2D LiDAR sensor
205
+ - ✅ External controllers
206
+ - ✅ Artificial Potential Fields (APF)
207
+ - ✅ Collision detection with geometry
208
+ - ✅ Custom rendering
209
+ - ✅ Real-time interaction
210
+
211
+ ---
212
+
213
+ ## 🚀 Roadmap
214
+
215
+ - Vortex APF
216
+ - Harmonic (Laplace) fields
217
+ - Local minima avoidance
218
+ - Advanced visualization
219
+
220
+ ---
221
+
222
+ ## 📄 License
223
+
224
+ Free for educational and research use.
@@ -0,0 +1,4 @@
1
+ from .config import load_config
2
+ from .simulator import Simulator
3
+
4
+ __all__ = ["Simulator", "load_config"]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ import numpy as np
4
+
5
+
6
+ @dataclass
7
+ class Camera2D:
8
+ world_width: float
9
+ world_height: float
10
+ viewport_width: int
11
+ viewport_height: int
12
+ initial_zoom: float = 1.0
13
+ min_zoom: float = 0.25
14
+ max_zoom: float = 8.0
15
+ zoom_step: float = 1.1
16
+ margin_x: float = 1.0
17
+ margin_y: float = 1.0
18
+
19
+ def __post_init__(self):
20
+ self.zoom = self.initial_zoom
21
+ self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
22
+ self.compute_scale()
23
+
24
+ def compute_scale(self):
25
+ scale_x = self.viewport_width / self.world_width
26
+ scale_y = self.viewport_height / self.world_height
27
+ self.base_scale = min(scale_x, scale_y)
28
+ self.scale = self.base_scale * self.zoom
29
+
30
+ def resize(self, viewport_width: int, viewport_height: int):
31
+ self.viewport_width = max(1, int(viewport_width))
32
+ self.viewport_height = max(1, int(viewport_height))
33
+ self.compute_scale()
34
+
35
+ def reset(self):
36
+ self.zoom = self.initial_zoom
37
+ self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
38
+ self.compute_scale()
39
+
40
+ def center_on_origin_with_margin(self):
41
+ self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
42
+
43
+ def zoom_at(self, mouse_pos, wheel_y: int):
44
+ old_world = self.screen_to_world(*mouse_pos)
45
+ factor = self.zoom_step if wheel_y > 0 else 1.0 / self.zoom_step
46
+ self.zoom = float(np.clip(self.zoom * factor, self.min_zoom, self.max_zoom))
47
+ self.compute_scale()
48
+ new_world = self.screen_to_world(*mouse_pos)
49
+ self.offset += old_world - new_world
50
+
51
+ def pan_pixels(self, dx: float, dy: float):
52
+ self.offset[0] -= dx / self.scale
53
+ self.offset[1] += dy / self.scale
54
+
55
+ def _display_offsets(self):
56
+ ox = (self.viewport_width - self.world_width * self.scale) / 2
57
+ oy = (self.viewport_height - self.world_height * self.scale) / 2
58
+ return ox, oy
59
+
60
+ def world_to_screen(self, px: float, py: float):
61
+ ox, oy = self._display_offsets()
62
+ px_cam = px - self.offset[0]
63
+ py_cam = py - self.offset[1]
64
+ sx = int(px_cam * self.scale + ox)
65
+ sy = int(self.viewport_height - (py_cam * self.scale + oy))
66
+ return sx, sy
67
+
68
+ def screen_to_world(self, sx: float, sy: float):
69
+ ox, oy = self._display_offsets()
70
+ px = (sx - ox) / self.scale + self.offset[0]
71
+ py = ((self.viewport_height - sy) - oy) / self.scale + self.offset[1]
72
+ return np.array([px, py], dtype=float)
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ from typing import Any
4
+ import yaml
5
+
6
+
7
+ def load_config(path: str | Path) -> dict[str, Any]:
8
+ path = Path(path)
9
+ with path.open("r", encoding="utf-8") as f:
10
+ return yaml.safe_load(f)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+
5
+ def wrap_to_pi(angle: float) -> float:
6
+ return float((angle + np.pi) % (2 * np.pi) - np.pi)
7
+
8
+
9
+ def clamp(value: float, min_value: float, max_value: float) -> float:
10
+ return max(min_value, min(value, max_value))
11
+
12
+
13
+ def distance_between(a, b) -> float:
14
+ return float(np.linalg.norm(np.asarray(a, dtype=float) - np.asarray(b, dtype=float)))
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import numpy as np
4
+ import pygame
5
+
6
+
7
+ class Lidar:
8
+ def __init__(self, config: dict):
9
+ c = config["lidar"]
10
+ self.enabled = bool(c.get("enabled", True))
11
+ self.num_rays = int(c.get("num_rays", 41))
12
+ self.range = float(c.get("range", 4.0))
13
+ self.fov = math.radians(float(c.get("fov_deg", 180)))
14
+ self.colors = config["colors"]
15
+
16
+ def ray_circle_intersection(self, ray_origin, ray_dir, obs):
17
+ circle_radius = obs[2]
18
+ circle_center = np.array(obs[:2])
19
+ oc = ray_origin - circle_center
20
+ a = np.dot(ray_dir, ray_dir)
21
+ b = 2.0 * np.dot(oc, ray_dir)
22
+ c = np.dot(oc, oc) - circle_radius ** 2
23
+ disc = b*b - 4*a*c
24
+ if disc < 0:
25
+ return None
26
+ sq = math.sqrt(disc)
27
+ valid = [t for t in [(-b - sq)/(2*a), (-b + sq)/(2*a)] if 0 <= t <= self.range]
28
+ return min(valid) if valid else None
29
+
30
+ def cast_rays(self, robot, world):
31
+ readings = []
32
+ angles = np.linspace(robot.theta - self.fov/2, robot.theta + self.fov/2, self.num_rays)
33
+ for angle in angles:
34
+ ray_dir = np.array([math.cos(angle), math.sin(angle)])
35
+ min_d = self.range
36
+ hit = robot.position + ray_dir * self.range
37
+ for obs in world.obstacles:
38
+ d = self.ray_circle_intersection(robot.position, ray_dir, obs)
39
+ if d is not None and d < min_d:
40
+ min_d = d
41
+ hit = robot.position + ray_dir * d
42
+ readings.append((angle, min_d, hit))
43
+ return readings
44
+
45
+ def draw(self, screen, camera, robot, world):
46
+ if not self.enabled:
47
+ return
48
+ origin = camera.world_to_screen(robot.x, robot.y)
49
+ color = tuple(self.colors["lidar"])
50
+ far = (80, 120, 120)
51
+ for _, dist, hit in self.cast_rays(robot, world):
52
+ pygame.draw.line(screen, color if dist < self.range else far, origin, camera.world_to_screen(*hit), 1)
53
+ if dist < self.range:
54
+ pygame.draw.circle(screen, color, camera.world_to_screen(*hit), 3)
@@ -0,0 +1,16 @@
1
+ from .holonomic import HolonomicRobot
2
+ from .differential import DifferentialRobot
3
+ from .ackermann import AckermannRobot
4
+
5
+
6
+ def create_robot(config: dict):
7
+ robot_type = config["robot"].get("type", "ackermann").lower()
8
+ if robot_type in ["holonomic", "omni", "omnidirectional"]:
9
+ return HolonomicRobot(config)
10
+ if robot_type in ["differential", "diff", "unicycle", "nonholonomic"]:
11
+ return DifferentialRobot(config)
12
+ if robot_type in ["ackermann", "car", "racecar"]:
13
+ return AckermannRobot(config)
14
+ raise ValueError(f"Tipo de robô desconhecido: {robot_type}")
15
+
16
+ __all__ = ["create_robot", "HolonomicRobot", "DifferentialRobot", "AckermannRobot"]