Implement KDL kinematics engine and complete core framework

Major Features Added:
- KDL-based kinematics engine with world/local coordinate transformation
- Complete GUI system with configuration management
- Robot loader with URDF parsing and joint control
- Enhanced environment system with transport object management
- Main application entry point with dependency validation

Technical Improvements:
- World coordinate to robot base coordinate transformation in KDL
- Real-time configuration updates through GUI
- Comprehensive error handling and validation
- Configuration-driven design throughout
- Robot model scaling adjusted to 0.2x for proper visualization

Files Added:
- src/robot/kinematics.py: KDL forward/inverse kinematics with coordinate transforms
- src/gui/: Complete GUI framework with main window and config management
- main.py: Application entry point with environment validation
- models/*.stl: Robot link mesh files for URDF visualization
- tests/: Unit test framework for core components

This commit establishes the complete foundation for robotic arm path planning
and task execution development.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sladro 2025-09-10 14:37:27 +08:00
parent a9e936bc43
commit 3b5306611a
24 changed files with 2407 additions and 35 deletions

View File

@ -1,45 +1,74 @@
{
"robot": {
"model_path": "models/manual_robot.urdf",
"base_position": [0.0, 0.0, 0.0],
"base_orientation": [0.0, 0.0, 0.0]
"base_position": [
0.0,
1.6,
0.0
],
"base_orientation": [
0.0,
0.0,
1.5
]
},
"wall": {
"position": [2.0, 0.0, 1.0],
"position": [
0.0,
0.0,
0.0
],
"dimensions": {
"width": 3.0,
"height": 2.5,
"thickness": 0.2
},
"material": {
"color": [0.8, 0.8, 0.8, 1.0],
"color": [
0.8,
0.8,
0.8,
1.0
],
"type": "concrete"
}
},
"hole": {
"position_relative_to_wall": [0.0, 0.0, 0.0],
"position_relative_to_wall": [
0.0,
0.0,
0.0
],
"dimensions": {
"width": 0.6,
"height": 0.6
},
"shape": "rectangle"
},
"task_points": {
"point_A": {
"position": [1.0, 0.5, 0.8],
"position": [
1.0,
0.5,
0.8
],
"description": "取物点,与机械臂同侧"
},
"point_B": {
"position": [3.0, 0.5, 0.8],
"position": [
3.0,
0.5,
0.8
],
"description": "目标点,墙体另一侧"
}
},
"transport_object": {
"initial_position": [1.0, 0.5, 0.5],
"initial_position": [
1.0,
0.5,
0.5
],
"dimensions": {
"width": 0.1,
"height": 0.1,
@ -48,14 +77,35 @@
"mass": 0.5,
"shape": "cube",
"material": {
"color": [1.0, 0.0, 0.0, 1.0],
"color": [
1.0,
0.0,
0.0,
1.0
],
"friction": 0.7
}
},
"simulation": {
"timestep": 0.01,
"gravity": [0.0, 0.0, -9.81],
"gravity": [
0.0,
0.0,
-9.81
],
"real_time": false
},
"camera": {
"distance": 4.0,
"yaw": 45,
"pitch": -30,
"target": [
0,
0,
0
]
},
"gui": {
"window_size": "1200x800"
}
}

88
main.py Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Robotic Arm Feasibility Test System
Main entry point for the robotic arm simulation system.
This system tests the feasibility of robotic arm operations in complex environments,
specifically transporting objects through obstacles.
"""
import sys
import json
from pathlib import Path
# Add src directory to Python path
PROJECT_ROOT = Path(__file__).parent.absolute()
sys.path.insert(0, str(PROJECT_ROOT / 'src'))
from src.gui.main_window import MainWindow
# Configuration constants
CONFIG_FILE = "config.json"
REQUIRED_MODULES = ['pybullet', 'tkinter', 'numpy']
def check_dependencies():
"""Check if all required dependencies are installed"""
missing = []
for module in REQUIRED_MODULES:
try:
__import__(module)
except ImportError:
missing.append(module)
if not missing:
return True
print(f"Missing dependencies: {', '.join(missing)}")
print(f"Install with: pip install {' '.join(missing)}")
return False
def verify_environment():
"""Verify that the environment is properly configured"""
# Check configuration file
config_path = PROJECT_ROOT / CONFIG_FILE
if not config_path.exists():
print(f"Error: {CONFIG_FILE} not found")
return False
# Check robot model file
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
model_path = config.get('robot', {}).get('model_path', '')
if model_path and not Path(model_path).exists():
print(f"Warning: Robot model not found: {model_path}")
# Continue anyway - let the simulation handle missing model
except (json.JSONDecodeError, IOError) as e:
print(f"Error reading configuration: {e}")
return False
return True
def main():
"""Main entry point"""
# Verify dependencies and environment
if not check_dependencies():
sys.exit(1)
if not verify_environment():
sys.exit(1)
# Start application
try:
app = MainWindow()
app.run()
except KeyboardInterrupt:
pass # Clean exit on Ctrl+C
except Exception as e:
print(f"Fatal error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

BIN
models/link_0.stl Normal file

Binary file not shown.

BIN
models/link_1.stl Normal file

Binary file not shown.

BIN
models/link_2.stl Normal file

Binary file not shown.

BIN
models/link_3.stl Normal file

Binary file not shown.

BIN
models/link_4.stl Normal file

Binary file not shown.

BIN
models/link_5.stl Normal file

Binary file not shown.

BIN
models/link_6.stl Normal file

Binary file not shown.

BIN
models/link_7.stl Normal file

Binary file not shown.

BIN
models/link_8.stl Normal file

Binary file not shown.

BIN
models/link_9.stl Normal file

Binary file not shown.

View File

@ -55,7 +55,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/> <!-- Mesh position and rotation relative to link coordinate system -->
<geometry>
<mesh filename="meshes/link_0.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
<mesh filename="link_0.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
</geometry>
<material name="orange"/> <!-- Use orange material -->
</visual>
@ -64,7 +64,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_0.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
<mesh filename="link_0.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
</geometry>
</collision>
@ -118,7 +118,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/> <!-- If mesh direction is wrong, can adjust rpy -->
<geometry>
<mesh filename="meshes/link_1.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
<mesh filename="link_1.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
</geometry>
<material name="blue"/> <!-- Use blue material -->
</visual>
@ -126,7 +126,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_1.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
<mesh filename="link_1.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
</geometry>
</collision>
@ -179,7 +179,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<!-- rpy="0 1.57 0" rotate 90 degrees around Y-axis -->
<!-- rpy="0 0 1.57" rotate 90 degrees around Z-axis -->
<geometry>
<mesh filename="meshes/link_2.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
<mesh filename="link_2.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
</geometry>
<material name="orange"/>
</visual>
@ -187,7 +187,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_2.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
<mesh filename="link_2.stl" scale="0.2 0.2 0.2"/> <!-- Use 0.2x scaled mesh size -->
</geometry>
</collision>
@ -235,7 +235,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_3.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_3.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="blue"/>
</visual>
@ -243,7 +243,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_3.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_3.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
@ -287,7 +287,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_4.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_4.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="orange"/>
</visual>
@ -295,7 +295,7 @@ Link masses decrease gradually from base to end effector, following robot arm de
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_4.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_4.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
@ -327,14 +327,14 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/> <!-- If need to adjust mesh direction, modify rpy -->
<geometry>
<mesh filename="meshes/link_5.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_5.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="blue"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_5.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_5.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
<inertial>
@ -358,14 +358,14 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_6.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_6.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="orange"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_6.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_6.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
<inertial>
@ -389,14 +389,14 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_7.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_7.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="blue"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_7.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_7.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
<inertial>
@ -420,14 +420,14 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_8.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_8.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="orange"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_8.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_8.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
<inertial>
@ -457,14 +457,14 @@ Link masses decrease gradually from base to end effector, following robot arm de
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_9.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_9.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
<material name="blue"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<mesh filename="meshes/link_9.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
<mesh filename="link_9.stl" scale="0.2 0.2 0.2"/> <!-- Use original mesh size -->
</geometry>
</collision>
<inertial>

View File

@ -0,0 +1,154 @@
import json
import os
from typing import Dict, Any, Optional
class ConfigLoader:
_instance: Optional['ConfigLoader'] = None
_config: Optional[Dict[str, Any]] = None
def __new__(cls, config_path: str = "config.json"):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, config_path: str = "config.json"):
if self._config is None:
self.config_path = config_path
self.load_config()
self.validate_config()
def load_config(self) -> None:
if not os.path.exists(self.config_path):
raise FileNotFoundError(f"Configuration file not found: {self.config_path}")
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
self._config = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON format in config file: {e}")
except Exception as e:
raise RuntimeError(f"Failed to load config file: {e}")
def validate_config(self) -> None:
required_sections = ['robot', 'wall', 'hole', 'task_points', 'transport_object', 'simulation']
for section in required_sections:
if section not in self._config:
raise ValueError(f"Missing required config section: {section}")
self._validate_robot_config()
self._validate_wall_config()
self._validate_hole_config()
self._validate_task_points()
self._validate_transport_object_config()
self._validate_simulation_config()
def _validate_robot_config(self) -> None:
robot_config = self._config['robot']
if 'model_path' not in robot_config:
raise ValueError("Robot config missing model_path")
model_path = robot_config['model_path']
if not os.path.exists(model_path):
raise FileNotFoundError(f"Robot model file not found: {model_path}")
if 'base_position' not in robot_config or len(robot_config['base_position']) != 3:
raise ValueError("Robot base_position must contain 3 coordinate values")
if 'base_orientation' not in robot_config or len(robot_config['base_orientation']) != 3:
raise ValueError("Robot base_orientation must contain 3 angle values")
def _validate_wall_config(self) -> None:
wall_config = self._config['wall']
if 'position' not in wall_config or len(wall_config['position']) != 3:
raise ValueError("Wall position must contain 3 coordinate values")
if 'dimensions' not in wall_config:
raise ValueError("Wall config missing dimensions")
dimensions = wall_config['dimensions']
required_dims = ['width', 'height', 'thickness']
for dim in required_dims:
if dim not in dimensions:
raise ValueError(f"Wall dimensions missing {dim}")
if dimensions[dim] <= 0:
raise ValueError(f"Wall {dim} must be greater than 0")
def _validate_hole_config(self) -> None:
hole_config = self._config['hole']
if 'position_relative_to_wall' not in hole_config or len(hole_config['position_relative_to_wall']) != 3:
raise ValueError("Hole position_relative_to_wall must contain 3 coordinate values")
if 'dimensions' not in hole_config:
raise ValueError("Hole config missing dimensions")
dimensions = hole_config['dimensions']
for dim in ['width', 'height']:
if dim not in dimensions:
raise ValueError(f"Hole dimensions missing {dim}")
if dimensions[dim] <= 0:
raise ValueError(f"Hole {dim} must be greater than 0")
def _validate_task_points(self) -> None:
task_points = self._config['task_points']
for point_name in ['point_A', 'point_B']:
if point_name not in task_points:
raise ValueError(f"Task points missing {point_name}")
point = task_points[point_name]
if 'position' not in point or len(point['position']) != 3:
raise ValueError(f"{point_name} position must contain 3 coordinate values")
def _validate_transport_object_config(self) -> None:
obj_config = self._config['transport_object']
if 'initial_position' not in obj_config or len(obj_config['initial_position']) != 3:
raise ValueError("Transport object initial_position must contain 3 coordinate values")
if 'dimensions' not in obj_config:
raise ValueError("Transport object config missing dimensions")
dimensions = obj_config['dimensions']
for dim in ['width', 'height', 'depth']:
if dim not in dimensions:
raise ValueError(f"Transport object dimensions missing {dim}")
if dimensions[dim] <= 0:
raise ValueError(f"Transport object {dim} must be greater than 0")
if 'mass' not in obj_config or obj_config['mass'] <= 0:
raise ValueError("Transport object mass must be greater than 0")
def _validate_simulation_config(self) -> None:
sim_config = self._config['simulation']
if 'timestep' not in sim_config or sim_config['timestep'] <= 0:
raise ValueError("Simulation timestep must be greater than 0")
if 'gravity' not in sim_config or len(sim_config['gravity']) != 3:
raise ValueError("Simulation gravity must contain 3 components")
def get_robot_config(self) -> Dict[str, Any]:
return self._config['robot'].copy()
def get_wall_config(self) -> Dict[str, Any]:
return self._config['wall'].copy()
def get_hole_config(self) -> Dict[str, Any]:
return self._config['hole'].copy()
def get_task_points(self) -> Dict[str, Any]:
return self._config['task_points'].copy()
def get_transport_object_config(self) -> Dict[str, Any]:
return self._config['transport_object'].copy()
def get_simulation_config(self) -> Dict[str, Any]:
return self._config['simulation'].copy()
def get_full_config(self) -> Dict[str, Any]:
return self._config.copy()

1
src/gui/__init__.py Normal file
View File

@ -0,0 +1 @@
# GUI module initialization

400
src/gui/config_window.py Normal file
View File

@ -0,0 +1,400 @@
import tkinter as tk
from tkinter import ttk, messagebox
import json
from typing import Dict, Any, Callable, Tuple, List
class ConfigWindow:
# UI Constants extracted from hardcoded values
WINDOW_GEOMETRY = "800x600"
BUTTON_COLORS = {
"apply": "#4CAF50",
"save": "#2196F3",
"reset": "#f44336"
}
FONT_TITLE = ("Arial", 12, "bold")
FONT_SECTION = ("Arial", 10, "bold")
PADDING_STANDARD = 5
PADDING_LARGE = 10
ENTRY_WIDTH_STANDARD = 10
ENTRY_WIDTH_LONG = 40
BUTTON_PADDING = 20
def __init__(self, parent, config_path: str = "config.json", on_apply_callback: Callable = None):
self.parent = parent
self.config_path = config_path
self.on_apply_callback = on_apply_callback
self.window = None
self.config_data = {}
self.entry_widgets = {}
# Load current configuration
self.load_config()
def load_config(self):
"""Load configuration from file"""
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
self.config_data = json.load(f)
except Exception as e:
# Follow fail-fast principle - raise exception instead of hiding it
raise RuntimeError(f"Failed to load configuration from {self.config_path}: {e}")
def save_config(self):
"""Save configuration to file"""
try:
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(self.config_data, f, indent=2, ensure_ascii=False)
return True
except Exception as e:
# Log error but allow UI to handle it
raise RuntimeError(f"Failed to save configuration to {self.config_path}: {e}")
def show(self):
"""Show configuration window"""
if self.window and self.window.winfo_exists():
self.window.lift()
return
self.window = tk.Toplevel(self.parent)
self.window.title("Configuration Management")
self.window.geometry(self.WINDOW_GEOMETRY)
# Create notebook for tabs
notebook = ttk.Notebook(self.window)
notebook.pack(fill=tk.BOTH, expand=True, padx=self.PADDING_LARGE, pady=self.PADDING_LARGE)
# Create tabs
self.create_robot_tab(notebook)
self.create_wall_tab(notebook)
self.create_hole_tab(notebook)
self.create_task_points_tab(notebook)
self.create_transport_object_tab(notebook)
self.create_simulation_tab(notebook)
# Create buttons frame
button_frame = tk.Frame(self.window)
button_frame.pack(fill=tk.X, padx=self.PADDING_LARGE, pady=self.PADDING_STANDARD)
# Apply button
apply_btn = tk.Button(button_frame, text="Apply", command=self.apply_changes,
bg=self.BUTTON_COLORS["apply"], fg="white", padx=self.BUTTON_PADDING)
apply_btn.pack(side=tk.LEFT, padx=self.PADDING_STANDARD)
# Save button
save_btn = tk.Button(button_frame, text="Save to File", command=self.save_changes,
bg=self.BUTTON_COLORS["save"], fg="white", padx=self.BUTTON_PADDING)
save_btn.pack(side=tk.LEFT, padx=self.PADDING_STANDARD)
# Reset button
reset_btn = tk.Button(button_frame, text="Reset", command=self.reset_values,
bg=self.BUTTON_COLORS["reset"], fg="white", padx=self.BUTTON_PADDING)
reset_btn.pack(side=tk.LEFT, padx=self.PADDING_STANDARD)
# Close button
close_btn = tk.Button(button_frame, text="Close", command=self.window.destroy,
padx=self.BUTTON_PADDING)
close_btn.pack(side=tk.RIGHT, padx=self.PADDING_STANDARD)
def _create_vector_input(self, parent: tk.Frame, row: int, label: str, key_prefix: str,
values: List[float], labels: List[str] = None) -> None:
"""Create vector input fields (x, y, z or custom labels)"""
if labels is None:
labels = ["X:", "Y:", "Z:"]
tk.Label(parent, text=label).grid(row=row, column=0, sticky="e",
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
for i, (lbl, val) in enumerate(zip(labels, values)):
tk.Label(parent, text=lbl).grid(row=row + i, column=1, sticky="e",
padx=self.PADDING_STANDARD)
entry_key = f"{key_prefix}.{lbl.lower().rstrip(':')}"
self.entry_widgets[entry_key] = tk.Entry(parent, width=self.ENTRY_WIDTH_STANDARD)
self.entry_widgets[entry_key].grid(row=row + i, column=2,
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets[entry_key].insert(0, str(val))
def _create_title_label(self, parent: tk.Frame, text: str, row: int = 0,
font_type: str = "title") -> None:
"""Create a title label"""
font = self.FONT_TITLE if font_type == "title" else self.FONT_SECTION
tk.Label(parent, text=text, font=font).grid(
row=row, column=0, columnspan=6, pady=self.PADDING_LARGE)
def _get_config_value(self, *keys, default=None):
"""Safely get nested configuration value"""
value = self.config_data
for key in keys:
if isinstance(value, dict):
value = value.get(key, {})
else:
return default
return value if value != {} else default
def create_robot_tab(self, notebook):
"""Create robot configuration tab"""
frame = ttk.Frame(notebook)
notebook.add(frame, text="Robot")
# Title
self._create_title_label(frame, "Robot Configuration")
# Model path
tk.Label(frame, text="Model Path:").grid(row=1, column=0, sticky="e",
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets['robot.model_path'] = tk.Entry(frame, width=self.ENTRY_WIDTH_LONG)
self.entry_widgets['robot.model_path'].grid(row=1, column=1, columnspan=3,
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets['robot.model_path'].insert(0, self._get_config_value('robot', 'model_path', default=''))
# Base position
base_pos = self._get_config_value('robot', 'base_position', default=[0, 0, 0])
self._create_vector_input(frame, 2, "Base Position:", "robot.base_position", base_pos)
# Base orientation
base_orient = self._get_config_value('robot', 'base_orientation', default=[0, 0, 0])
self._create_vector_input(frame, 5, "Base Orientation:", "robot.base_orientation",
base_orient, labels=["Roll:", "Pitch:", "Yaw:"])
def _create_dimension_inputs(self, parent: tk.Frame, row: int, key_prefix: str,
dimensions: Dict[str, float], dim_names: List[str]) -> None:
"""Create dimension input fields"""
tk.Label(parent, text="Dimensions:").grid(row=row, column=0, sticky="e",
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
for i, dim_name in enumerate(dim_names):
tk.Label(parent, text=f"{dim_name.capitalize()}:").grid(row=row + i, column=1, sticky="e",
padx=self.PADDING_STANDARD)
entry_key = f"{key_prefix}.{dim_name}"
self.entry_widgets[entry_key] = tk.Entry(parent, width=self.ENTRY_WIDTH_STANDARD)
self.entry_widgets[entry_key].grid(row=row + i, column=2,
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
# Get default value from config or use 0
default_val = dimensions.get(dim_name, 0.0)
self.entry_widgets[entry_key].insert(0, str(default_val))
def create_wall_tab(self, notebook):
"""Create wall configuration tab"""
frame = ttk.Frame(notebook)
notebook.add(frame, text="Wall")
# Title
self._create_title_label(frame, "Wall Configuration")
# Wall position
wall_pos = self._get_config_value('wall', 'position', default=[2.0, 0.0, 1.0])
self._create_vector_input(frame, 1, "Position:", "wall.position", wall_pos)
# Wall dimensions
wall_dims = self._get_config_value('wall', 'dimensions', default={})
# Set defaults if not in config
wall_dims.setdefault('width', 3.0)
wall_dims.setdefault('height', 2.5)
wall_dims.setdefault('thickness', 0.2)
self._create_dimension_inputs(frame, 4, "wall.dimensions", wall_dims,
['width', 'height', 'thickness'])
def create_hole_tab(self, notebook):
"""Create hole configuration tab"""
frame = ttk.Frame(notebook)
notebook.add(frame, text="Hole")
# Title
self._create_title_label(frame, "Hole Configuration")
# Hole position relative to wall
hole_pos = self._get_config_value('hole', 'position_relative_to_wall', default=[0, 0, 0])
self._create_vector_input(frame, 1, "Position (relative):", "hole.position", hole_pos)
# Hole dimensions
hole_dims = self._get_config_value('hole', 'dimensions', default={})
hole_dims.setdefault('width', 0.6)
hole_dims.setdefault('height', 0.6)
self._create_dimension_inputs(frame, 4, "hole.dimensions", hole_dims, ['width', 'height'])
def _create_horizontal_vector_input(self, parent: tk.Frame, row: int, label: str,
key_prefix: str, values: List[float]) -> None:
"""Create horizontal vector input fields (for compact layout)"""
labels = ["X:", "Y:", "Z:"]
for i, (lbl, val) in enumerate(zip(labels, values)):
tk.Label(parent, text=lbl).grid(row=row, column=i*2, sticky="e",
padx=self.PADDING_STANDARD)
entry_key = f"{key_prefix}.{lbl.lower().rstrip(':')}"
self.entry_widgets[entry_key] = tk.Entry(parent, width=self.ENTRY_WIDTH_STANDARD)
self.entry_widgets[entry_key].grid(row=row, column=i*2+1,
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets[entry_key].insert(0, str(val))
def create_task_points_tab(self, notebook):
"""Create task points configuration tab"""
frame = ttk.Frame(notebook)
notebook.add(frame, text="Task Points")
# Title
self._create_title_label(frame, "Task Points Configuration")
# Point A
self._create_title_label(frame, "Point A (Pick-up):", row=1, font_type="section")
point_a = self._get_config_value('task_points', 'point_A', 'position',
default=[1.0, 0.5, 0.8])
self._create_horizontal_vector_input(frame, 2, "Point A", "point_a", point_a)
# Point B
self._create_title_label(frame, "Point B (Drop-off):", row=3, font_type="section")
point_b = self._get_config_value('task_points', 'point_B', 'position',
default=[3.0, 0.5, 0.8])
self._create_horizontal_vector_input(frame, 4, "Point B", "point_b", point_b)
def create_transport_object_tab(self, notebook):
"""Create transport object configuration tab"""
frame = ttk.Frame(notebook)
notebook.add(frame, text="Transport Object")
# Title
self._create_title_label(frame, "Transport Object Configuration")
# Initial position
obj_pos = self._get_config_value('transport_object', 'initial_position',
default=[1.0, 0.5, 0.5])
self._create_vector_input(frame, 1, "Initial Position:", "object.position", obj_pos)
# Dimensions
obj_dims = self._get_config_value('transport_object', 'dimensions', default={})
obj_dims.setdefault('width', 0.1)
obj_dims.setdefault('height', 0.1)
obj_dims.setdefault('depth', 0.1)
self._create_dimension_inputs(frame, 4, "object.dimensions", obj_dims,
['width', 'height', 'depth'])
# Mass
mass = self._get_config_value('transport_object', 'mass', default=0.5)
tk.Label(frame, text="Mass (kg):").grid(row=7, column=0, sticky="e",
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets['object.mass'] = tk.Entry(frame, width=self.ENTRY_WIDTH_STANDARD)
self.entry_widgets['object.mass'].grid(row=7, column=2,
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets['object.mass'].insert(0, str(mass))
def create_simulation_tab(self, notebook):
"""Create simulation configuration tab"""
frame = ttk.Frame(notebook)
notebook.add(frame, text="Simulation")
# Title
self._create_title_label(frame, "Simulation Configuration")
# Timestep
timestep = self._get_config_value('simulation', 'timestep', default=0.01)
tk.Label(frame, text="Timestep:").grid(row=1, column=0, sticky="e",
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets['simulation.timestep'] = tk.Entry(frame, width=self.ENTRY_WIDTH_STANDARD)
self.entry_widgets['simulation.timestep'].grid(row=1, column=1,
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.entry_widgets['simulation.timestep'].insert(0, str(timestep))
# Gravity
gravity = self._get_config_value('simulation', 'gravity', default=[0, 0, -9.81])
self._create_vector_input(frame, 2, "Gravity:", "simulation.gravity", gravity)
# Real-time mode
real_time = self._get_config_value('simulation', 'real_time', default=False)
tk.Label(frame, text="Real-time Mode:").grid(row=5, column=0, sticky="e",
padx=self.PADDING_STANDARD, pady=self.PADDING_STANDARD)
self.real_time_var = tk.BooleanVar()
self.real_time_var.set(real_time)
tk.Checkbutton(frame, variable=self.real_time_var).grid(row=5, column=1,
padx=self.PADDING_STANDARD,
pady=self.PADDING_STANDARD, sticky="w")
def _get_vector_from_widgets(self, key_prefix: str) -> List[float]:
"""Get vector values from widgets"""
return [
float(self.entry_widgets[f'{key_prefix}.x'].get()),
float(self.entry_widgets[f'{key_prefix}.y'].get()),
float(self.entry_widgets[f'{key_prefix}.z'].get())
]
def _get_vector_custom_from_widgets(self, key_prefix: str, labels: List[str]) -> List[float]:
"""Get vector values from widgets with custom labels"""
return [float(self.entry_widgets[f'{key_prefix}.{label}'].get()) for label in labels]
def _ensure_nested_dict(self, *keys) -> None:
"""Ensure nested dictionary structure exists"""
current = self.config_data
for key in keys:
if key not in current:
current[key] = {}
current = current[key]
def get_values_from_widgets(self) -> Dict[str, Any]:
"""Get values from all entry widgets and update config_data"""
try:
# Ensure all necessary nested structures exist
self._ensure_nested_dict('robot')
self._ensure_nested_dict('wall', 'dimensions')
self._ensure_nested_dict('hole', 'dimensions')
self._ensure_nested_dict('task_points', 'point_A')
self._ensure_nested_dict('task_points', 'point_B')
self._ensure_nested_dict('transport_object', 'dimensions')
self._ensure_nested_dict('simulation')
# Robot configuration
self.config_data['robot']['model_path'] = self.entry_widgets['robot.model_path'].get()
self.config_data['robot']['base_position'] = self._get_vector_from_widgets('robot.base_position')
self.config_data['robot']['base_orientation'] = self._get_vector_custom_from_widgets(
'robot.base_orientation', ['roll', 'pitch', 'yaw'])
# Wall configuration
self.config_data['wall']['position'] = self._get_vector_from_widgets('wall.position')
for dim in ['width', 'height', 'thickness']:
self.config_data['wall']['dimensions'][dim] = float(
self.entry_widgets[f'wall.dimensions.{dim}'].get())
# Hole configuration
self.config_data['hole']['position_relative_to_wall'] = self._get_vector_from_widgets('hole.position')
for dim in ['width', 'height']:
self.config_data['hole']['dimensions'][dim] = float(
self.entry_widgets[f'hole.dimensions.{dim}'].get())
# Task points
self.config_data['task_points']['point_A']['position'] = self._get_vector_from_widgets('point_a')
self.config_data['task_points']['point_B']['position'] = self._get_vector_from_widgets('point_b')
# Transport object
self.config_data['transport_object']['initial_position'] = self._get_vector_from_widgets('object.position')
for dim in ['width', 'height', 'depth']:
self.config_data['transport_object']['dimensions'][dim] = float(
self.entry_widgets[f'object.dimensions.{dim}'].get())
self.config_data['transport_object']['mass'] = float(self.entry_widgets['object.mass'].get())
# Simulation
self.config_data['simulation']['timestep'] = float(self.entry_widgets['simulation.timestep'].get())
self.config_data['simulation']['gravity'] = self._get_vector_from_widgets('simulation.gravity')
self.config_data['simulation']['real_time'] = self.real_time_var.get()
return self.config_data
except (ValueError, KeyError) as e:
# Allow UI to handle the error display
raise ValueError(f"Invalid input or configuration structure: {e}")
def apply_changes(self):
"""Apply configuration changes"""
config = self.get_values_from_widgets()
if self.on_apply_callback:
self.on_apply_callback(config)
def save_changes(self):
"""Save configuration changes to file"""
self.config_data = self.get_values_from_widgets()
self.save_config()
def reset_values(self):
"""Reset values to original configuration"""
self.load_config()
# Refresh window
if self.window:
self.window.destroy()
self.show()

434
src/gui/main_window.py Normal file
View File

@ -0,0 +1,434 @@
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
import pybullet as p
import pybullet_data
import threading
import time
from typing import Dict, Any
import sys
import os
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from src.config_loader import ConfigLoader
from src.robot.robot_loader import RobotLoader
from src.simulation.environment import Environment
from src.gui.config_window import ConfigWindow
class MainWindow:
def __init__(self):
self.root = tk.Tk()
self.root.title("Robotic Arm Simulation - Feasibility Test")
# Get window dimensions from config or use defaults
self._setup_window_geometry()
# Simulation components
self.physics_client = None
self.config_loader = None
self.robot_loader = None
self.environment = None
self.config_window = None
# Simulation state
self.simulation_running = False
self.simulation_thread = None
# Create GUI
self._create_control_panel()
self._create_log_panel()
self._create_bottom_panel()
# Initialize simulation
self.initialize_simulation()
def _setup_window_geometry(self):
"""Setup window geometry from config or defaults"""
try:
# Try to load from config if exists
config = ConfigLoader().get_full_config()
window_size = config.get('gui', {}).get('window_size', "1200x800")
self.root.geometry(window_size)
except Exception as e:
# Use config default if loading fails
default_config = {"gui": {"window_size": "1200x800"}}
self.root.geometry(default_config['gui']['window_size'])
def _create_control_panel(self):
"""Create control panel on the left side"""
# Create main frame
self.main_frame = tk.Frame(self.root)
self.main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Left panel - Control panel
left_panel = tk.Frame(self.main_frame, width=300)
left_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
# Title
title_label = tk.Label(left_panel, text="Control Panel",
font=("Arial", 14, "bold"))
title_label.pack(pady=(0, 10))
# Configuration section
config_frame = tk.LabelFrame(left_panel, text="Configuration", padx=10, pady=10)
config_frame.pack(fill=tk.X, pady=(0, 10))
self.config_btn = tk.Button(config_frame, text="Open Config Manager",
command=self.open_config_window,
bg="#2196F3", fg="white", padx=10, pady=5)
self.config_btn.pack(fill=tk.X)
self.reload_btn = tk.Button(config_frame, text="Reload Config",
command=self.reload_configuration,
padx=10, pady=5)
self.reload_btn.pack(fill=tk.X, pady=(5, 0))
# Simulation control section
control_frame = tk.LabelFrame(left_panel, text="Simulation Control", padx=10, pady=10)
control_frame.pack(fill=tk.X, pady=(0, 10))
self.start_btn = tk.Button(control_frame, text="Start Simulation",
command=self.start_simulation,
bg="#4CAF50", fg="white", padx=10, pady=5)
self.start_btn.pack(fill=tk.X)
self.pause_btn = tk.Button(control_frame, text="Pause Simulation",
command=self.pause_simulation,
state=tk.DISABLED, padx=10, pady=5)
self.pause_btn.pack(fill=tk.X, pady=(5, 0))
self.reset_btn = tk.Button(control_frame, text="Reset Environment",
command=self.reset_environment,
bg="#FF9800", fg="white", padx=10, pady=5)
self.reset_btn.pack(fill=tk.X, pady=(5, 0))
# Reachability test section
test_frame = tk.LabelFrame(left_panel, text="Reachability Test", padx=10, pady=10)
test_frame.pack(fill=tk.X, pady=(0, 10))
self.test_btn = tk.Button(test_frame, text="Test Reachability",
command=self.test_reachability,
bg="#9C27B0", fg="white", padx=10, pady=5)
self.test_btn.pack(fill=tk.X)
tk.Label(test_frame, text="Check if A and B points are reachable",
font=("Arial", 9)).pack(pady=(5, 0))
# Status section
status_frame = tk.LabelFrame(left_panel, text="Status", padx=10, pady=10)
status_frame.pack(fill=tk.X, pady=(0, 10))
# Robot status
tk.Label(status_frame, text="Robot:", font=("Arial", 9, "bold")).grid(row=0, column=0, sticky="w")
self.robot_status = tk.Label(status_frame, text="Not Loaded", fg="red")
self.robot_status.grid(row=0, column=1, sticky="w", padx=(5, 0))
# Environment status
tk.Label(status_frame, text="Environment:", font=("Arial", 9, "bold")).grid(row=1, column=0, sticky="w")
self.env_status = tk.Label(status_frame, text="Not Loaded", fg="red")
self.env_status.grid(row=1, column=1, sticky="w", padx=(5, 0))
# Simulation status
tk.Label(status_frame, text="Simulation:", font=("Arial", 9, "bold")).grid(row=2, column=0, sticky="w")
self.sim_status = tk.Label(status_frame, text="Stopped", fg="orange")
self.sim_status.grid(row=2, column=1, sticky="w", padx=(5, 0))
def _create_log_panel(self):
"""Create log panel with copyable text"""
from tkinter import scrolledtext
# Right panel - Log display
right_panel = tk.Frame(self.main_frame)
right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Log display frame
log_label = tk.Label(right_panel, text="System Log", font=("Arial", 12, "bold"))
log_label.pack(pady=(0, 5))
# Scrollable text widget for logs (can select and copy)
self.log_text = scrolledtext.ScrolledText(right_panel, height=25, wrap=tk.WORD)
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Make it read-only initially but still selectable
self.log_text.config(state=tk.DISABLED)
def _create_bottom_panel(self):
"""Create bottom panel with viewer info and exit button"""
bottom_frame = tk.Frame(self.root)
bottom_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
tk.Label(bottom_frame, text="PyBullet Viewer:", font=("Arial", 9, "bold")).pack(side=tk.LEFT, padx=(0, 5))
tk.Label(bottom_frame, text="Use external PyBullet GUI window for 3D visualization",
fg="gray").pack(side=tk.LEFT)
# Exit button
exit_btn = tk.Button(bottom_frame, text="Exit", command=self.on_closing,
bg="#f44336", fg="white", padx=20)
exit_btn.pack(side=tk.RIGHT)
def initialize_simulation(self):
"""Initialize PyBullet simulation"""
try:
# Connect to PyBullet in GUI mode
self.physics_client = p.connect(p.GUI)
p.setAdditionalSearchPath(pybullet_data.getDataPath())
# Configure visualization
p.configureDebugVisualizer(p.COV_ENABLE_GUI, 1)
p.configureDebugVisualizer(p.COV_ENABLE_SHADOWS, 1)
p.configureDebugVisualizer(p.COV_ENABLE_RENDERING, 1)
# Set camera from config or defaults
self._reset_camera_view()
# Load configuration
self.config_loader = ConfigLoader()
# Setup environment
self.environment = Environment(self.config_loader, self.physics_client)
self.environment.setup_environment()
self.env_status.config(text="Loaded", fg="green")
# Load robot
self.robot_loader = RobotLoader(self.config_loader, self.physics_client)
self.robot_loader.load_robot()
self.robot_status.config(text="Loaded", fg="green")
self.update_status("System ready")
except Exception as e:
self.update_status(f"Initialization failed: {e}")
messagebox.showerror("Initialization Error", f"Failed to initialize simulation:\n{e}")
def open_config_window(self):
"""Open configuration management window"""
if not self.config_window:
self.config_window = ConfigWindow(
self.root,
on_apply_callback=self.on_config_applied
)
self.config_window.show()
def on_config_applied(self, new_config: Dict[str, Any]):
"""Handle configuration changes from config window"""
self.update_status("Applying configuration...")
try:
# Save current robot state if exists
robot_state = None
if self.robot_loader and self.robot_loader.robot_id is not None:
robot_state = self.robot_loader.get_joint_states()
# Clear current environment
if self.environment:
self.environment.cleanup()
if self.robot_loader and self.robot_loader.robot_id is not None:
p.removeBody(self.robot_loader.robot_id, physicsClientId=self.physics_client)
# Create new ConfigLoader with updated configuration
self.config_loader = ConfigLoader()
# Update the config loader's internal config directly
self.config_loader._config = new_config
# Recreate environment
self.environment = Environment(self.config_loader, self.physics_client)
self.environment.setup_environment()
# Reload robot
self.robot_loader = RobotLoader(self.config_loader, self.physics_client)
self.robot_loader.load_robot()
# Restore robot state if available
if robot_state:
try:
positions = [state['position'] for state in robot_state.values()]
self.robot_loader.set_joint_positions(positions)
except Exception:
# State restoration failed, continue with defaults
pass
self.update_status("Configuration applied")
except Exception as e:
self.update_status(f"Configuration error: {e}")
messagebox.showerror("Configuration Error", f"Failed to apply configuration:\n{e}")
def reload_configuration(self):
"""Reload configuration from file"""
try:
self.config_loader = ConfigLoader()
self.on_config_applied(self.config_loader.get_full_config())
self.update_status("Configuration reloaded")
except Exception as e:
self.update_status(f"Reload failed: {e}")
def start_simulation(self):
"""Start simulation loop"""
if not self.simulation_running:
self.simulation_running = True
self.sim_status.config(text="Running", fg="green")
self.start_btn.config(state=tk.DISABLED)
self.pause_btn.config(state=tk.NORMAL)
# Start simulation thread
self.simulation_thread = threading.Thread(target=self.simulation_loop, daemon=True)
self.simulation_thread.start()
self.update_status("Simulation running")
def pause_simulation(self):
"""Pause simulation loop"""
if self.simulation_running:
self.simulation_running = False
self.sim_status.config(text="Paused", fg="orange")
self.start_btn.config(state=tk.NORMAL)
self.pause_btn.config(state=tk.DISABLED)
self.update_status("Simulation paused")
def simulation_loop(self):
"""Main simulation loop (runs in separate thread)"""
while self.simulation_running:
try:
p.stepSimulation(physicsClientId=self.physics_client)
# Get timestep from config
timestep = self.config_loader.get_full_config().get('simulation', {}).get('timestep', 0.01)
time.sleep(timestep)
except Exception as e:
self.update_status(f"Simulation error: {e}")
self.simulation_running = False
break
def reset_environment(self):
"""Reset environment to initial state"""
try:
# Reset robot
if self.robot_loader:
self.robot_loader.reset_robot()
# Reset environment objects
if self.environment:
self.environment.reset_environment()
# Reset camera
self._reset_camera_view()
self.update_status("Environment reset")
except Exception as e:
self.update_status(f"Reset failed: {e}")
def test_reachability(self):
"""Test reachability of task points"""
self.update_status("Testing reachability...")
try:
# Get task points
task_points = self.config_loader.get_task_points()
point_a = task_points['point_A']['position']
point_b = task_points['point_B']['position']
# Perform reachability check
self._check_reachability(point_a, point_b)
except Exception as e:
self.update_status(f"Reachability test failed: {e}")
raise
def _check_reachability(self, point_a, point_b):
"""Check if points are reachable by the robot"""
# Calculate distances
distance_a = self._calculate_distance(point_a)
distance_b = self._calculate_distance(point_b)
# Get robot reach
ee_pos, _ = self.robot_loader.get_end_effector_pose()
current_reach = self._calculate_distance(ee_pos)
# Check reachability
a_reachable = distance_a <= current_reach
b_reachable = distance_b <= current_reach
if a_reachable and b_reachable:
self.update_status("Both points are reachable")
elif not a_reachable:
self.update_status(f"Point A out of reach ({distance_a:.2f}m > {current_reach:.2f}m)")
elif not b_reachable:
self.update_status(f"Point B out of reach ({distance_b:.2f}m > {current_reach:.2f}m)")
def _calculate_distance(self, point):
"""Calculate Euclidean distance from origin"""
return (point[0]**2 + point[1]**2 + point[2]**2) ** 0.5
def _reset_camera_view(self):
"""Reset camera to default view using config values"""
try:
# Get camera settings from config
config = self.config_loader.get_full_config() if self.config_loader else {}
camera_config = config.get('camera', {})
# Use config values with explicit defaults from config structure
default_camera = {'distance': 4.0, 'yaw': 45, 'pitch': -30, 'target': [0, 0, 0]}
distance = camera_config.get('distance', default_camera['distance'])
yaw = camera_config.get('yaw', default_camera['yaw'])
pitch = camera_config.get('pitch', default_camera['pitch'])
target = camera_config.get('target', default_camera['target'])
p.resetDebugVisualizerCamera(
cameraDistance=distance,
cameraYaw=yaw,
cameraPitch=pitch,
cameraTargetPosition=target,
physicsClientId=self.physics_client
)
except Exception as e:
# Re-raise exception for proper error handling
raise Exception(f"Failed to reset camera view: {e}")
def update_status(self, message: str):
"""Update status display with copyable text"""
import time
if hasattr(self, 'log_text'):
# Enable text widget to add content
self.log_text.config(state=tk.NORMAL)
# Add timestamp and message
timestamp = time.strftime("%H:%M:%S")
self.log_text.insert(tk.END, f"[{timestamp}] {message}\n")
# Auto-scroll to bottom
self.log_text.see(tk.END)
# Make read-only again but still selectable
self.log_text.config(state=tk.DISABLED)
def on_closing(self):
"""Handle window closing"""
if messagebox.askokcancel("Quit", "Do you want to quit the simulation?"):
try:
# Stop simulation
self.simulation_running = False
# Cleanup
if self.environment:
self.environment.cleanup()
# Disconnect PyBullet
if self.physics_client is not None:
p.disconnect(self.physics_client)
except Exception as e:
# Log cleanup failure but still close
print(f"Cleanup error: {e}")
self.root.destroy()
def run(self):
"""Start the main window"""
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
self.root.mainloop()
if __name__ == "__main__":
# Test the main window directly
app = MainWindow()
app.run()

View File

@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
KDL-based Kinematics Engine
Provides forward and inverse kinematics calculations using KDL library.
Supports URDF-based robot configurations for precise kinematic computations.
"""
import PyKDL as kdl
import numpy as np
from typing import List, Tuple, Optional, Dict
from urdf_parser_py.urdf import URDF
import sys
import os
class KinematicsEngine:
"""KDL-based kinematics engine for robot arm calculations"""
def __init__(self, urdf_path: str, base_link: str, end_link: str,
base_position: List[float] = None, base_orientation: List[float] = None):
"""Initialize KDL kinematics engine from URDF file"""
self.urdf_path = urdf_path
self.base_link = base_link
self.end_link = end_link
# Base coordinate transformation (base pose in world frame)
self.base_position = base_position if base_position else [0.0, 0.0, 0.0]
self.base_orientation = base_orientation if base_orientation else [0.0, 0.0, 0.0] # RPY
self._base_to_world_frame = self._create_transform_frame(self.base_position, self.base_orientation)
self._world_to_base_frame = self._base_to_world_frame.Inverse()
# KDL components
self.chain: Optional[kdl.Chain] = None
self.fk_solver: Optional[kdl.ChainFkSolverPos_recursive] = None
self.ik_solver: Optional[kdl.ChainIkSolverPos_NR_JL] = None
self.jac_solver: Optional[kdl.ChainJntToJacSolver] = None
self.joint_limits: List[Tuple[float, float]] = []
# Initialize KDL chain and solvers
self._build_kdl_chain()
self._create_solvers()
def _build_kdl_chain(self) -> None:
"""Build KDL chain from URDF file"""
try:
# Parse URDF
robot = URDF.from_xml_file(self.urdf_path)
# Build chain from base to end effector
self.chain = self._urdf_to_kdl_chain(robot, self.base_link, self.end_link)
if self.chain is None:
raise RuntimeError(f"Failed to build KDL chain from {self.base_link} to {self.end_link}")
except Exception as e:
raise RuntimeError(f"Failed to build KDL chain: {e}")
def _urdf_to_kdl_chain(self, robot: URDF, base_link: str, end_link: str) -> kdl.Chain:
"""Convert URDF robot to KDL chain"""
chain = kdl.Chain()
# Find path from base to end link
path = self._find_link_path(robot, base_link, end_link)
if not path:
raise RuntimeError(f"No path found from {base_link} to {end_link}")
# Build chain along the path
for i in range(len(path) - 1):
current_link = path[i]
next_link = path[i + 1]
# Find joint connecting these links
joint = self._find_joint_between_links(robot, current_link, next_link)
if joint is None:
continue
# Convert joint to KDL segment
segment = self._joint_to_kdl_segment(joint, robot)
chain.addSegment(segment)
# Store joint limits for controllable joints
if joint.type in ['revolute', 'prismatic']:
if joint.limit:
self.joint_limits.append((joint.limit.lower, joint.limit.upper))
else:
# Default large limits if not specified
self.joint_limits.append((-3.14159, 3.14159))
return chain
def _find_link_path(self, robot: URDF, start_link: str, end_link: str) -> List[str]:
"""Find path between two links in URDF tree"""
if start_link == end_link:
return [start_link]
# Build parent-child relationships
parent_map = {}
for joint in robot.joints:
parent_map[joint.child] = joint.parent
# Find path from end to start
path = [end_link]
current = end_link
while current != start_link and current in parent_map:
current = parent_map[current]
path.append(current)
if current != start_link:
return [] # No path found
# Reverse to get start->end path
return list(reversed(path))
def _find_joint_between_links(self, robot: URDF, parent_link: str, child_link: str):
"""Find joint connecting two links"""
for joint in robot.joints:
if joint.parent == parent_link and joint.child == child_link:
return joint
return None
def _joint_to_kdl_segment(self, joint, robot: URDF) -> kdl.Segment:
"""Convert URDF joint to KDL segment"""
# Get joint origin
origin = joint.origin if joint.origin else URDF.Origin()
xyz = origin.xyz if origin.xyz else [0, 0, 0]
rpy = origin.rpy if origin.rpy else [0, 0, 0]
# Create KDL frame
rotation = kdl.Rotation.RPY(rpy[0], rpy[1], rpy[2])
translation = kdl.Vector(xyz[0], xyz[1], xyz[2])
frame = kdl.Frame(rotation, translation)
# Create KDL joint
if joint.type == 'revolute':
axis = joint.axis if joint.axis else [0, 0, 1]
kdl_joint = kdl.Joint(joint.name, kdl.Joint.RotZ if axis == [0, 0, 1]
else kdl.Joint.RotY if axis == [0, 1, 0]
else kdl.Joint.RotX)
elif joint.type == 'prismatic':
axis = joint.axis if joint.axis else [0, 0, 1]
kdl_joint = kdl.Joint(joint.name, kdl.Joint.TransZ if axis == [0, 0, 1]
else kdl.Joint.TransY if axis == [0, 1, 0]
else kdl.Joint.TransX)
else:
# Fixed joint
kdl_joint = kdl.Joint(joint.name, kdl.Joint.None)
return kdl.Segment(joint.child, kdl_joint, frame)
def _create_solvers(self) -> None:
"""Create KDL solvers for forward kinematics, inverse kinematics and Jacobian"""
if self.chain is None:
raise RuntimeError("KDL chain not initialized")
try:
# Forward kinematics solver
self.fk_solver = kdl.ChainFkSolverPos_recursive(self.chain)
# Jacobian solver
self.jac_solver = kdl.ChainJntToJacSolver(self.chain)
# Inverse kinematics solver with joint limits
if self.joint_limits:
q_min = kdl.JntArray(len(self.joint_limits))
q_max = kdl.JntArray(len(self.joint_limits))
for i, (lower, upper) in enumerate(self.joint_limits):
q_min[i] = lower
q_max[i] = upper
# Create velocity IK solver first
ik_vel_solver = kdl.ChainIkSolverVel_pinv(self.chain)
# Create position IK solver with joint limits
self.ik_solver = kdl.ChainIkSolverPos_NR_JL(
self.chain, q_min, q_max, self.fk_solver, ik_vel_solver
)
else:
# Fallback without joint limits
ik_vel_solver = kdl.ChainIkSolverVel_pinv(self.chain)
self.ik_solver = kdl.ChainIkSolverPos_NR(
self.chain, self.fk_solver, ik_vel_solver
)
except Exception as e:
raise RuntimeError(f"Failed to create KDL solvers: {e}")
def forward_kinematics(self, joint_positions: List[float]) -> Tuple[List[float], List[float]]:
"""Calculate forward kinematics from joint positions to world coordinate end effector pose"""
if self.fk_solver is None:
raise RuntimeError("Forward kinematics solver not initialized")
if len(joint_positions) != self.chain.getNrOfJoints():
raise ValueError(f"Expected {self.chain.getNrOfJoints()} joint positions, got {len(joint_positions)}")
# Create joint array
q = kdl.JntArray(len(joint_positions))
for i, pos in enumerate(joint_positions):
q[i] = pos
# Solve forward kinematics in base frame
base_end_frame = kdl.Frame()
result = self.fk_solver.JntToCart(q, base_end_frame)
if result != 0:
raise RuntimeError("Forward kinematics calculation failed")
# Transform from base frame to world frame
world_end_frame = self._base_to_world_frame * base_end_frame
# Extract world position
position = [
world_end_frame.p.x(),
world_end_frame.p.y(),
world_end_frame.p.z()
]
# Extract world orientation as quaternion
rotation = world_end_frame.M
orientation = self._rotation_to_quaternion(rotation)
return position, orientation
def inverse_kinematics(self, target_position: List[float],
target_orientation: List[float] = None,
seed_angles: List[float] = None) -> Optional[List[float]]:
"""Calculate inverse kinematics from world coordinate target pose to joint positions"""
if self.ik_solver is None:
raise RuntimeError("Inverse kinematics solver not initialized")
# Create world target frame
world_target_frame = kdl.Frame()
world_target_frame.p = kdl.Vector(target_position[0], target_position[1], target_position[2])
if target_orientation is not None:
world_target_frame.M = self._quaternion_to_rotation(target_orientation)
# Transform from world frame to base frame
base_target_frame = self._world_to_base_frame * world_target_frame
# Set seed angles
q_init = kdl.JntArray(self.chain.getNrOfJoints())
if seed_angles:
if len(seed_angles) != self.chain.getNrOfJoints():
raise ValueError(f"Expected {self.chain.getNrOfJoints()} seed angles, got {len(seed_angles)}")
for i, angle in enumerate(seed_angles):
q_init[i] = angle
else:
# Use zero as default seed
for i in range(self.chain.getNrOfJoints()):
q_init[i] = 0.0
# Solve inverse kinematics in base frame
q_out = kdl.JntArray(self.chain.getNrOfJoints())
result = self.ik_solver.CartToJnt(q_init, base_target_frame, q_out)
if result < 0:
return None # No solution found
# Return joint angles
joint_angles = []
for i in range(q_out.rows()):
joint_angles.append(q_out[i])
return joint_angles
def compute_jacobian(self, joint_positions: List[float]) -> np.ndarray:
"""Compute Jacobian matrix for given joint configuration"""
if self.jac_solver is None:
raise RuntimeError("Jacobian solver not initialized")
if len(joint_positions) != self.chain.getNrOfJoints():
raise ValueError(f"Expected {self.chain.getNrOfJoints()} joint positions, got {len(joint_positions)}")
# Create joint array
q = kdl.JntArray(len(joint_positions))
for i, pos in enumerate(joint_positions):
q[i] = pos
# Compute Jacobian
jacobian = kdl.Jacobian(self.chain.getNrOfJoints())
result = self.jac_solver.JntToJac(q, jacobian)
if result != 0:
raise RuntimeError("Jacobian calculation failed")
# Convert to numpy array
jac_array = np.zeros((6, self.chain.getNrOfJoints()))
for i in range(6):
for j in range(self.chain.getNrOfJoints()):
jac_array[i, j] = jacobian[i, j]
return jac_array
def get_joint_limits(self) -> List[Tuple[float, float]]:
"""Get joint limits for all joints in the chain"""
return self.joint_limits.copy()
def validate_joint_configuration(self, joint_positions: List[float]) -> bool:
"""Validate if joint configuration is within limits"""
if len(joint_positions) != len(self.joint_limits):
return False
for i, (pos, (lower, upper)) in enumerate(zip(joint_positions, self.joint_limits)):
if pos < lower or pos > upper:
return False
return True
def get_num_joints(self) -> int:
"""Get number of joints in the kinematic chain"""
return self.chain.getNrOfJoints() if self.chain else 0
def _rotation_to_quaternion(self, rotation: kdl.Rotation) -> List[float]:
"""Convert KDL rotation to quaternion [x, y, z, w]"""
# Get quaternion from KDL rotation
quat = rotation.GetQuaternion()
return [quat[0], quat[1], quat[2], quat[3]] # [x, y, z, w]
def _quaternion_to_rotation(self, quaternion: List[float]) -> kdl.Rotation:
"""Convert quaternion [x, y, z, w] to KDL rotation"""
return kdl.Rotation.Quaternion(quaternion[0], quaternion[1], quaternion[2], quaternion[3])
def _create_transform_frame(self, position: List[float], orientation_rpy: List[float]) -> kdl.Frame:
"""Create KDL frame from position and RPY orientation"""
rotation = kdl.Rotation.RPY(orientation_rpy[0], orientation_rpy[1], orientation_rpy[2])
translation = kdl.Vector(position[0], position[1], position[2])
return kdl.Frame(rotation, translation)
def create_kinematics_engine(config_loader) -> KinematicsEngine:
"""Create KinematicsEngine from configuration loader"""
robot_config = config_loader.get_robot_config()
urdf_path = robot_config['model_path']
# Get robot base pose from config
base_position = robot_config.get('base_position', [0.0, 0.0, 0.0])
base_orientation = robot_config.get('base_orientation', [0.0, 0.0, 0.0])
# Get link names from config or use defaults
kinematics_config = robot_config.get('kinematics', {})
base_link = kinematics_config.get('base_link', 'base_link')
end_link = kinematics_config.get('end_link', 'link_9')
return KinematicsEngine(urdf_path, base_link, end_link, base_position, base_orientation)

304
src/robot/robot_loader.py Normal file
View File

@ -0,0 +1,304 @@
import pybullet as p
import os
from typing import Dict, List, Tuple, Any, Optional
from ..config_loader import ConfigLoader
class RobotLoader:
def __init__(self, config_loader: ConfigLoader, physics_client: int):
self.config_loader = config_loader
self.physics_client = physics_client
self.robot_id: Optional[int] = None
self.num_joints: int = 0
self.joint_indices: List[int] = []
self.joint_info: Dict[int, Dict[str, Any]] = {}
self.link_info: Dict[int, Dict[str, Any]] = {}
self.joint_name_to_index: Dict[str, int] = {}
self.end_effector_index: Optional[int] = None
# Get robot configuration
self.robot_config = config_loader.get_robot_config()
self.base_position = self.robot_config['base_position']
self.base_orientation = self.robot_config['base_orientation']
# Get expected DOF from config if available
self.expected_dof = self.robot_config.get('dof', None)
def load_robot(self) -> int:
"""Load robot URDF model into pybullet simulation"""
model_path = self.robot_config['model_path']
if not os.path.exists(model_path):
raise FileNotFoundError(f"Robot URDF file not found: {model_path}")
# Convert RPY to quaternion for pybullet
base_orientation_quat = p.getQuaternionFromEuler(self.base_orientation)
# Load URDF model
self.robot_id = p.loadURDF(
model_path,
basePosition=self.base_position,
baseOrientation=base_orientation_quat,
physicsClientId=self.physics_client
)
if self.robot_id is None:
raise RuntimeError(f"Failed to load robot URDF: {model_path}")
# Parse robot structure
self._parse_robot_structure()
# Validate robot DOF if specified in config
if self.expected_dof is not None:
if len(self.joint_indices) != self.expected_dof:
raise ValueError(f"Expected {self.expected_dof} DOF robot, found {len(self.joint_indices)} controllable joints")
# Set initial joint positions if available in config
self.reset_robot()
return self.robot_id
def _parse_robot_structure(self) -> None:
"""Parse robot structure and extract joint/link information"""
self.num_joints = p.getNumJoints(self.robot_id, physicsClientId=self.physics_client)
self.joint_indices = []
self.joint_info = {}
self.link_info = {}
self.joint_name_to_index = {}
for joint_index in range(self.num_joints):
joint_info = p.getJointInfo(self.robot_id, joint_index, physicsClientId=self.physics_client)
joint_name = joint_info[1].decode('utf-8')
joint_type = joint_info[2]
# Store joint information
self.joint_info[joint_index] = {
'name': joint_name,
'type': joint_type,
'lower_limit': joint_info[8],
'upper_limit': joint_info[9],
'max_force': joint_info[10],
'max_velocity': joint_info[11],
'axis': joint_info[13]
}
self.joint_name_to_index[joint_name] = joint_index
# Only include revolute and prismatic joints as controllable
if joint_type in [p.JOINT_REVOLUTE, p.JOINT_PRISMATIC]:
self.joint_indices.append(joint_index)
# Find end effector (last link)
if self.num_joints > 0:
self.end_effector_index = self.num_joints - 1
def get_joint_states(self) -> Dict[str, Dict[str, float]]:
"""Get current states of all controllable joints"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
joint_states = {}
for joint_index in self.joint_indices:
state = p.getJointState(self.robot_id, joint_index, physicsClientId=self.physics_client)
joint_name = self.joint_info[joint_index]['name']
joint_states[joint_name] = {
'position': state[0],
'velocity': state[1],
'reaction_forces': state[2],
'applied_torque': state[3]
}
return joint_states
def get_joint_limits(self) -> Dict[str, Tuple[float, float]]:
"""Get joint limits for all controllable joints"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
joint_limits = {}
for joint_index in self.joint_indices:
joint_name = self.joint_info[joint_index]['name']
lower_limit = self.joint_info[joint_index]['lower_limit']
upper_limit = self.joint_info[joint_index]['upper_limit']
joint_limits[joint_name] = (lower_limit, upper_limit)
return joint_limits
def get_end_effector_pose(self) -> Tuple[List[float], List[float]]:
"""Get end effector pose (position and orientation)"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
if self.end_effector_index is None:
raise RuntimeError("End effector not identified")
link_state = p.getLinkState(
self.robot_id,
self.end_effector_index,
physicsClientId=self.physics_client
)
position = list(link_state[0])
orientation = list(link_state[1])
return position, orientation
def get_link_state(self, link_index: int) -> Dict[str, Any]:
"""Get state of a specific link"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
link_state = p.getLinkState(
self.robot_id,
link_index,
physicsClientId=self.physics_client
)
return {
'position': list(link_state[0]),
'orientation': list(link_state[1]),
'local_inertial_position': list(link_state[2]),
'local_inertial_orientation': list(link_state[3]),
'world_position': list(link_state[4]),
'world_orientation': list(link_state[5])
}
def set_joint_positions(self, joint_positions: List[float]) -> None:
"""Set target positions for all controllable joints"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
if len(joint_positions) != len(self.joint_indices):
raise ValueError(f"Expected {len(self.joint_indices)} joint positions, got {len(joint_positions)}")
# Validate joint positions
if not self.validate_joint_positions(joint_positions):
raise ValueError("Joint positions exceed limits")
# Set joint positions
for i, joint_index in enumerate(self.joint_indices):
p.setJointMotorControl2(
self.robot_id,
joint_index,
p.POSITION_CONTROL,
targetPosition=joint_positions[i],
physicsClientId=self.physics_client
)
def set_joint_velocities(self, joint_velocities: List[float]) -> None:
"""Set target velocities for all controllable joints"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
if len(joint_velocities) != len(self.joint_indices):
raise ValueError(f"Expected {len(self.joint_indices)} joint velocities, got {len(joint_velocities)}")
# Set joint velocities
for i, joint_index in enumerate(self.joint_indices):
p.setJointMotorControl2(
self.robot_id,
joint_index,
p.VELOCITY_CONTROL,
targetVelocity=joint_velocities[i],
physicsClientId=self.physics_client
)
def reset_robot(self) -> None:
"""Reset robot to initial configuration"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
# Get initial positions from config or use zeros
initial_positions = self.robot_config.get('initial_joint_positions', [0.0] * len(self.joint_indices))
# Ensure initial positions list matches joint count
if len(initial_positions) != len(self.joint_indices):
initial_positions = [0.0] * len(self.joint_indices)
for i, joint_index in enumerate(self.joint_indices):
p.resetJointState(
self.robot_id,
joint_index,
initial_positions[i],
physicsClientId=self.physics_client
)
def validate_joint_positions(self, positions: List[float]) -> bool:
"""Validate if joint positions are within limits"""
if len(positions) != len(self.joint_indices):
return False
for i, joint_index in enumerate(self.joint_indices):
lower_limit = self.joint_info[joint_index]['lower_limit']
upper_limit = self.joint_info[joint_index]['upper_limit']
# Skip validation for unlimited joints
if lower_limit == 0.0 and upper_limit == -1.0:
continue
if positions[i] < lower_limit or positions[i] > upper_limit:
return False
return True
def check_self_collision(self) -> bool:
"""Check if robot has self-collision"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
# Get contact points for self-collision detection
contact_points = p.getContactPoints(
bodyA=self.robot_id,
bodyB=self.robot_id,
physicsClientId=self.physics_client
)
return len(contact_points) > 0
def get_joint_names(self) -> List[str]:
"""Get names of all controllable joints"""
return [self.joint_info[idx]['name'] for idx in self.joint_indices]
def get_joint_info(self) -> Dict[int, Dict[str, Any]]:
"""Get complete joint information"""
return self.joint_info.copy()
def get_robot_id(self) -> Optional[int]:
"""Get pybullet robot ID"""
return self.robot_id
def get_dof(self) -> int:
"""Get number of degrees of freedom"""
return len(self.joint_indices)
def enable_torque_control(self) -> None:
"""Switch to torque control mode"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
for joint_index in self.joint_indices:
p.setJointMotorControl2(
self.robot_id,
joint_index,
p.TORQUE_CONTROL,
force=0,
physicsClientId=self.physics_client
)
def enable_position_control(self) -> None:
"""Switch to position control mode"""
if self.robot_id is None:
raise RuntimeError("Robot not loaded. Call load_robot() first.")
# Get current positions
current_states = self.get_joint_states()
current_positions = [state['position'] for state in current_states.values()]
# Set position control with current positions as targets
self.set_joint_positions(current_positions)

View File

@ -0,0 +1,317 @@
import pybullet as p
import pybullet_data
from typing import Dict, List, Any, Optional, Tuple
from ..config_loader import ConfigLoader
class Environment:
def __init__(self, config_loader: ConfigLoader, physics_client: int):
self.config_loader = config_loader
self.physics_client = physics_client
# Get configurations
self.wall_config = config_loader.get_wall_config()
self.hole_config = config_loader.get_hole_config()
self.transport_object_config = config_loader.get_transport_object_config()
self.simulation_config = config_loader.get_simulation_config()
# Environment object IDs
self.wall_id: Optional[int] = None
self.transport_object_id: Optional[int] = None
self.ground_plane_id: Optional[int] = None
def setup_simulation(self) -> None:
"""Setup basic simulation environment"""
# Set additional search path for pybullet data
p.setAdditionalSearchPath(pybullet_data.getDataPath())
# Set gravity
gravity = self.simulation_config['gravity']
p.setGravity(gravity[0], gravity[1], gravity[2], physicsClientId=self.physics_client)
# Set time step
timestep = self.simulation_config['timestep']
p.setTimeStep(timestep, physicsClientId=self.physics_client)
# Set real time simulation
real_time = self.simulation_config.get('real_time', False)
if real_time:
p.setRealTimeSimulation(1, physicsClientId=self.physics_client)
else:
p.setRealTimeSimulation(0, physicsClientId=self.physics_client)
def create_ground_plane(self) -> int:
"""Create ground plane"""
self.ground_plane_id = p.loadURDF(
"plane.urdf",
physicsClientId=self.physics_client
)
return self.ground_plane_id
def create_wall_with_hole(self) -> int:
"""Create wall with hole obstacle"""
wall_pos = self.wall_config['position']
wall_dims = self.wall_config['dimensions']
hole_dims = self.hole_config['dimensions']
hole_pos_rel = self.hole_config['position_relative_to_wall']
# Calculate wall dimensions
wall_width = wall_dims['width']
wall_height = wall_dims['height']
wall_thickness = wall_dims['thickness']
# Calculate hole dimensions
hole_width = hole_dims['width']
hole_height = hole_dims['height']
# Calculate absolute hole position
hole_pos = [
wall_pos[0] + hole_pos_rel[0],
wall_pos[1] + hole_pos_rel[1],
wall_pos[2] + hole_pos_rel[2]
]
# Create wall with hole using multiple boxes
wall_parts = []
# Wall parameters
wall_color = self.wall_config.get('material', {}).get('color', [0.8, 0.8, 0.8, 1.0])
# Bottom part (below hole)
bottom_height = hole_pos[2] - wall_pos[2] - hole_height / 2
if bottom_height > 0:
bottom_pos = [wall_pos[0], wall_pos[1], wall_pos[2] + bottom_height / 2]
bottom_id = self._create_box(
[wall_width / 2, wall_thickness / 2, bottom_height / 2],
bottom_pos,
wall_color
)
wall_parts.append(bottom_id)
# Top part (above hole)
top_height = wall_pos[2] + wall_height - (hole_pos[2] + hole_height / 2)
if top_height > 0:
top_pos = [wall_pos[0], wall_pos[1], hole_pos[2] + hole_height / 2 + top_height / 2]
top_id = self._create_box(
[wall_width / 2, wall_thickness / 2, top_height / 2],
top_pos,
wall_color
)
wall_parts.append(top_id)
# Left part (left of hole)
left_width = hole_pos[0] - wall_pos[0] - hole_width / 2 + wall_width / 2
if left_width > 0:
left_pos = [wall_pos[0] - wall_width / 2 + left_width / 2, wall_pos[1], hole_pos[2]]
left_id = self._create_box(
[left_width / 2, wall_thickness / 2, hole_height / 2],
left_pos,
wall_color
)
wall_parts.append(left_id)
# Right part (right of hole)
right_width = wall_pos[0] + wall_width / 2 - (hole_pos[0] + hole_width / 2)
if right_width > 0:
right_pos = [hole_pos[0] + hole_width / 2 + right_width / 2, wall_pos[1], hole_pos[2]]
right_id = self._create_box(
[right_width / 2, wall_thickness / 2, hole_height / 2],
right_pos,
wall_color
)
wall_parts.append(right_id)
# Store the first wall part as main wall ID
if wall_parts:
self.wall_id = wall_parts[0]
return wall_parts
def _create_box(self, half_extents: List[float], position: List[float], color: List[float]) -> int:
"""Create a box collision shape and body"""
# Create collision shape
collision_shape = p.createCollisionShape(
p.GEOM_BOX,
halfExtents=half_extents,
physicsClientId=self.physics_client
)
# Create visual shape
visual_shape = p.createVisualShape(
p.GEOM_BOX,
halfExtents=half_extents,
rgbaColor=color,
physicsClientId=self.physics_client
)
# Create multi-body
body_id = p.createMultiBody(
baseMass=0, # Static object
baseCollisionShapeIndex=collision_shape,
baseVisualShapeIndex=visual_shape,
basePosition=position,
physicsClientId=self.physics_client
)
return body_id
def create_transport_object(self) -> int:
"""Create the object to be transported"""
obj_config = self.transport_object_config
# Get object parameters
initial_pos = obj_config['initial_position']
dimensions = obj_config['dimensions']
mass = obj_config['mass']
shape = obj_config.get('shape', 'cube')
color = obj_config.get('material', {}).get('color', [1.0, 0.0, 0.0, 1.0])
if shape == 'cube':
# Create cube
half_extents = [
dimensions['width'] / 2,
dimensions['height'] / 2,
dimensions['depth'] / 2
]
collision_shape = p.createCollisionShape(
p.GEOM_BOX,
halfExtents=half_extents,
physicsClientId=self.physics_client
)
visual_shape = p.createVisualShape(
p.GEOM_BOX,
halfExtents=half_extents,
rgbaColor=color,
physicsClientId=self.physics_client
)
else:
raise ValueError(f"Unsupported transport object shape: {shape}")
# Create multi-body
self.transport_object_id = p.createMultiBody(
baseMass=mass,
baseCollisionShapeIndex=collision_shape,
baseVisualShapeIndex=visual_shape,
basePosition=initial_pos,
physicsClientId=self.physics_client
)
return self.transport_object_id
def setup_environment(self) -> Dict[str, Any]:
"""Setup complete environment and return object IDs"""
# Setup basic simulation parameters
self.setup_simulation()
# Create ground plane
ground_id = self.create_ground_plane()
# Create wall with hole
wall_parts = self.create_wall_with_hole()
# Create transport object
transport_obj_id = self.create_transport_object()
return {
'ground_plane_id': ground_id,
'wall_parts': wall_parts,
'transport_object_id': transport_obj_id
}
def get_transport_object_pose(self) -> Tuple[List[float], List[float]]:
"""Get current pose of transport object"""
if self.transport_object_id is None:
raise RuntimeError("Transport object not created")
pos, orient = p.getBasePositionAndOrientation(
self.transport_object_id,
physicsClientId=self.physics_client
)
return list(pos), list(orient)
def set_transport_object_pose(self, position: List[float], orientation: List[float] = None) -> None:
"""Set pose of transport object"""
if self.transport_object_id is None:
raise RuntimeError("Transport object not created")
if orientation is None:
orientation = [0, 0, 0, 1] # Default quaternion
p.resetBasePositionAndOrientation(
self.transport_object_id,
position,
orientation,
physicsClientId=self.physics_client
)
def check_collision(self, body_a: int, body_b: int = None) -> bool:
"""Check collision between bodies"""
if body_b is None:
# Check collision with any other body
contact_points = p.getContactPoints(
bodyA=body_a,
physicsClientId=self.physics_client
)
else:
# Check collision between specific bodies
contact_points = p.getContactPoints(
bodyA=body_a,
bodyB=body_b,
physicsClientId=self.physics_client
)
return len(contact_points) > 0
def get_hole_info(self) -> Dict[str, Any]:
"""Get hole information for path planning"""
wall_pos = self.wall_config['position']
hole_pos_rel = self.hole_config['position_relative_to_wall']
hole_dims = self.hole_config['dimensions']
# Calculate absolute hole position
hole_center = [
wall_pos[0] + hole_pos_rel[0],
wall_pos[1] + hole_pos_rel[1],
wall_pos[2] + hole_pos_rel[2]
]
return {
'center': hole_center,
'dimensions': hole_dims,
'shape': self.hole_config.get('shape', 'rectangle')
}
def get_wall_info(self) -> Dict[str, Any]:
"""Get wall information for path planning"""
return {
'position': self.wall_config['position'],
'dimensions': self.wall_config['dimensions']
}
def reset_environment(self) -> None:
"""Reset environment to initial state"""
if self.transport_object_id is not None:
initial_pos = self.transport_object_config['initial_position']
self.set_transport_object_pose(initial_pos)
def cleanup(self) -> None:
"""Clean up environment objects"""
objects_to_remove = []
if self.wall_id is not None:
objects_to_remove.append(self.wall_id)
if self.transport_object_id is not None:
objects_to_remove.append(self.transport_object_id)
if self.ground_plane_id is not None:
objects_to_remove.append(self.ground_plane_id)
for obj_id in objects_to_remove:
try:
p.removeBody(obj_id, physicsClientId=self.physics_client)
except:
pass # Object might already be removed

0
tests/__init__.py Normal file
View File

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config_loader import ConfigLoader
def test_config_loader():
print("Testing ConfigLoader...")
# Test singleton pattern
loader1 = ConfigLoader()
loader2 = ConfigLoader()
assert loader1 is loader2, "ConfigLoader should be singleton"
print("✓ Singleton pattern works")
# Test config loading
config = loader1.get_full_config()
assert 'robot' in config, "Config should contain robot section"
assert 'wall' in config, "Config should contain wall section"
assert 'hole' in config, "Config should contain hole section"
assert 'task_points' in config, "Config should contain task_points section"
assert 'transport_object' in config, "Config should contain transport_object section"
assert 'simulation' in config, "Config should contain simulation section"
print("✓ All required config sections present")
# Test robot config
robot_config = loader1.get_robot_config()
assert robot_config['model_path'] == "models/manual_robot.urdf", "Robot model path should be correct"
assert len(robot_config['base_position']) == 3, "Robot base position should have 3 coordinates"
assert len(robot_config['base_orientation']) == 3, "Robot base orientation should have 3 angles"
print("✓ Robot config validation passed")
# Test wall config
wall_config = loader1.get_wall_config()
assert len(wall_config['position']) == 3, "Wall position should have 3 coordinates"
assert 'dimensions' in wall_config, "Wall should have dimensions"
assert wall_config['dimensions']['width'] > 0, "Wall width should be positive"
assert wall_config['dimensions']['height'] > 0, "Wall height should be positive"
assert wall_config['dimensions']['thickness'] > 0, "Wall thickness should be positive"
print("✓ Wall config validation passed")
# Test hole config
hole_config = loader1.get_hole_config()
assert len(hole_config['position_relative_to_wall']) == 3, "Hole position should have 3 coordinates"
assert 'dimensions' in hole_config, "Hole should have dimensions"
assert hole_config['dimensions']['width'] > 0, "Hole width should be positive"
assert hole_config['dimensions']['height'] > 0, "Hole height should be positive"
print("✓ Hole config validation passed")
# Test task points
task_points = loader1.get_task_points()
assert 'point_A' in task_points, "Should have point_A"
assert 'point_B' in task_points, "Should have point_B"
assert len(task_points['point_A']['position']) == 3, "Point A should have 3 coordinates"
assert len(task_points['point_B']['position']) == 3, "Point B should have 3 coordinates"
print("✓ Task points validation passed")
# Test transport object config
obj_config = loader1.get_transport_object_config()
assert len(obj_config['initial_position']) == 3, "Object initial position should have 3 coordinates"
assert 'dimensions' in obj_config, "Object should have dimensions"
assert obj_config['mass'] > 0, "Object mass should be positive"
print("✓ Transport object config validation passed")
# Test simulation config
sim_config = loader1.get_simulation_config()
assert sim_config['timestep'] > 0, "Timestep should be positive"
assert len(sim_config['gravity']) == 3, "Gravity should have 3 components"
print("✓ Simulation config validation passed")
print("\n✅ All tests passed! ConfigLoader is working correctly.")
# Print sample data
print("\nSample configuration data:")
print(f"Robot model: {robot_config['model_path']}")
print(f"Wall position: {wall_config['position']}")
print(f"Wall dimensions: {wall_config['dimensions']}")
print(f"Hole dimensions: {hole_config['dimensions']}")
print(f"Point A: {task_points['point_A']['position']}")
print(f"Point B: {task_points['point_B']['position']}")
print(f"Object mass: {obj_config['mass']} kg")
print(f"Simulation timestep: {sim_config['timestep']} s")
if __name__ == "__main__":
test_config_loader()

94
tests/test_environment.py Normal file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pybullet as p
from src.config_loader import ConfigLoader
from src.simulation.environment import Environment
def test_environment():
print("Testing Environment...")
# Initialize pybullet in DIRECT mode for testing
physics_client = p.connect(p.DIRECT)
try:
# Test environment initialization
config_loader = ConfigLoader()
environment = Environment(config_loader, physics_client)
print("✓ Environment initialization successful")
# Test environment setup
env_objects = environment.setup_environment()
print("✓ Environment setup successful")
# Verify all objects were created
assert 'ground_plane_id' in env_objects, "Ground plane should be created"
assert 'wall_parts' in env_objects, "Wall parts should be created"
assert 'transport_object_id' in env_objects, "Transport object should be created"
print(f"✓ Created objects: ground={env_objects['ground_plane_id']}, wall_parts={len(env_objects['wall_parts'])}, object={env_objects['transport_object_id']}")
# Test transport object pose
obj_pos, obj_orient = environment.get_transport_object_pose()
assert len(obj_pos) == 3, "Object position should have 3 coordinates"
assert len(obj_orient) == 4, "Object orientation should have 4 quaternion values"
print(f"✓ Transport object pose: pos={obj_pos}, orient={obj_orient}")
# Test setting object pose
new_pos = [1.5, 0.0, 0.5]
environment.set_transport_object_pose(new_pos)
updated_pos, _ = environment.get_transport_object_pose()
assert abs(updated_pos[0] - new_pos[0]) < 0.01, "Object position should be updated"
print("✓ Transport object pose setting works")
# Test hole info
hole_info = environment.get_hole_info()
assert 'center' in hole_info, "Hole info should include center"
assert 'dimensions' in hole_info, "Hole info should include dimensions"
assert 'shape' in hole_info, "Hole info should include shape"
print(f"✓ Hole info: center={hole_info['center']}, dims={hole_info['dimensions']}")
# Test wall info
wall_info = environment.get_wall_info()
assert 'position' in wall_info, "Wall info should include position"
assert 'dimensions' in wall_info, "Wall info should include dimensions"
print(f"✓ Wall info: pos={wall_info['position']}, dims={wall_info['dimensions']}")
# Test environment reset
environment.reset_environment()
reset_pos, _ = environment.get_transport_object_pose()
expected_pos = config_loader.get_transport_object_config()['initial_position']
assert abs(reset_pos[0] - expected_pos[0]) < 0.01, "Object should be reset to initial position"
print("✓ Environment reset successful")
# Test collision detection
ground_id = env_objects['ground_plane_id']
transport_id = env_objects['transport_object_id']
has_collision = environment.check_collision(transport_id, ground_id)
print(f"✓ Collision detection: {has_collision}")
# Test cleanup
environment.cleanup()
print("✓ Environment cleanup successful")
print("\n✅ All Environment tests passed!")
# Print environment information
print(f"\nEnvironment Information:")
print(f"- Wall parts created: {len(env_objects['wall_parts'])}")
print(f"- Transport object mass: {config_loader.get_transport_object_config()['mass']} kg")
print(f"- Hole dimensions: {hole_info['dimensions']}")
print(f"- Wall dimensions: {wall_info['dimensions']}")
except Exception as e:
print(f"❌ Test failed: {e}")
raise
finally:
# Cleanup
p.disconnect(physics_client)
print("✓ PyBullet disconnected")
if __name__ == "__main__":
test_environment()

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pybullet as p
from src.config_loader import ConfigLoader
from src.robot.robot_loader import RobotLoader
def test_robot_loader():
print("Testing RobotLoader...")
# Initialize pybullet in DIRECT mode for testing
physics_client = p.connect(p.DIRECT)
try:
# Test config loading
config_loader = ConfigLoader()
robot_loader = RobotLoader(config_loader, physics_client)
print("✓ RobotLoader initialization successful")
# Test robot loading
robot_id = robot_loader.load_robot()
assert robot_id is not None, "Robot ID should not be None"
print(f"✓ Robot loaded successfully with ID: {robot_id}")
# Test DOF
dof = robot_loader.get_dof()
print(f"✓ Robot has {dof} degrees of freedom")
# Test joint names
joint_names = robot_loader.get_joint_names()
assert len(joint_names) == dof, "Joint names count should match DOF"
print(f"✓ Joint names: {joint_names[:3]}..." if len(joint_names) > 3 else f"✓ Joint names: {joint_names}")
# Test joint limits
joint_limits = robot_loader.get_joint_limits()
assert len(joint_limits) == dof, "Joint limits count should match DOF"
print("✓ Joint limits retrieved successfully")
# Test joint states
joint_states = robot_loader.get_joint_states()
assert len(joint_states) == dof, "Joint states count should match DOF"
print("✓ Joint states retrieved successfully")
# Test end effector pose
ee_pos, ee_orient = robot_loader.get_end_effector_pose()
assert len(ee_pos) == 3, "End effector position should have 3 coordinates"
assert len(ee_orient) == 4, "End effector orientation should have 4 quaternion values"
print(f"✓ End effector pose: pos={ee_pos}, orient={ee_orient}")
# Test joint position validation
zero_positions = [0.0] * dof
is_valid = robot_loader.validate_joint_positions(zero_positions)
print(f"✓ Zero positions validation: {is_valid}")
# Test setting joint positions
robot_loader.set_joint_positions(zero_positions)
print("✓ Joint positions set successfully")
# Test robot reset
robot_loader.reset_robot()
print("✓ Robot reset successful")
# Test self-collision check
has_collision = robot_loader.check_self_collision()
print(f"✓ Self-collision check: {has_collision}")
# Test control mode switching
robot_loader.enable_position_control()
print("✓ Position control mode enabled")
robot_loader.enable_torque_control()
print("✓ Torque control mode enabled")
print("\n✅ All RobotLoader tests passed!")
# Print robot information
print(f"\nRobot Information:")
print(f"- DOF: {dof}")
print(f"- Robot ID: {robot_id}")
print(f"- Joint count: {robot_loader.num_joints}")
print(f"- Controllable joints: {len(robot_loader.joint_indices)}")
print(f"- End effector index: {robot_loader.end_effector_index}")
except Exception as e:
print(f"❌ Test failed: {e}")
raise
finally:
# Cleanup
p.disconnect(physics_client)
print("✓ PyBullet disconnected")
if __name__ == "__main__":
test_robot_loader()