diff --git a/CMakeLists.txt b/CMakeLists.txt index b8c12ff..118b766 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,24 +6,40 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -# Add pybind11 -add_subdirectory(external/pybind11) +# Add pybind11 (use submodule if available, otherwise fetch) +include(FetchContent) +set(PYBIND11_SOURCE_DIR ${CMAKE_SOURCE_DIR}/external/pybind11) +if(EXISTS ${PYBIND11_SOURCE_DIR}/CMakeLists.txt) + add_subdirectory(${PYBIND11_SOURCE_DIR}) +else() + message(WARNING "pybind11 submodule not found, fetching release archive") + FetchContent_Declare( + pybind11 + URL https://github.com/pybind/pybind11/archive/refs/tags/v2.12.0.tar.gz + ) + FetchContent_MakeAvailable(pybind11) +endif() + +find_package(OpenGL REQUIRED) # Core C++ library (static library for standalone testing) add_library(rc_car_core STATIC cpp/src/math_operations.cpp + cpp/src/renderer3d.cpp ) target_include_directories(rc_car_core PUBLIC ${CMAKE_SOURCE_DIR}/cpp/include ) +target_link_libraries(rc_car_core PUBLIC OpenGL::GL) + # Python module (DLL/shared library) pybind11_add_module(rc_car_cpp cpp/src/bindings.cpp ) -target_link_libraries(rc_car_cpp PRIVATE rc_car_core) +target_link_libraries(rc_car_cpp PRIVATE rc_car_core OpenGL::GL) target_include_directories(rc_car_cpp PRIVATE ${CMAKE_SOURCE_DIR}/cpp/include diff --git a/README.md b/README.md index f8d99d0..6818688 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ GUI for RC car telemetry and controls with high-performance C++ extensions. ## Features - PyQt6-based graphical interface for RC car control - High-performance C++ extensions via Pybind11 for robotics calculations +- OpenGL-backed 3D visualization widget with Python bindings - CMake-based build system with MSVC support - Standalone C++ testing capabilities @@ -20,6 +21,7 @@ GUI for RC car telemetry and controls with high-performance C++ extensions. - CMake 3.15 or higher - Microsoft Visual Studio (with C++ Desktop Development workload) on Windows - GCC or Clang on Linux/macOS +- OpenGL development libraries (e.g., `libgl1-mesa-dev` and `libglu1-mesa-dev` on Linux) ## Installation diff --git a/cpp/include/renderer3d.h b/cpp/include/renderer3d.h new file mode 100644 index 0000000..4f5c02e --- /dev/null +++ b/cpp/include/renderer3d.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +namespace rc_car { + +/** + * Minimal OpenGL-based renderer for showcasing 3D content. + * + * Rendering calls assume an active OpenGL context (provided by the caller). + */ +class Renderer3D { +public: + Renderer3D(); + + /** + * Set the clear color used before drawing. + */ + void setClearColor(float r, float g, float b, float a = 1.0f); + + /** + * Render a colored cube using the current OpenGL context. + * + * @param angleXDeg Rotation around the X axis in degrees. + * @param angleYDeg Rotation around the Y axis in degrees. + * @param distance Camera distance from the object. + * @param width Viewport width in pixels. + * @param height Viewport height in pixels. + */ + void renderCube(float angleXDeg, float angleYDeg, float distance, int width, int height) const; + +private: + std::array clearColor_; +}; + +} // namespace rc_car diff --git a/cpp/src/bindings.cpp b/cpp/src/bindings.cpp index 8ea3e89..0393568 100644 --- a/cpp/src/bindings.cpp +++ b/cpp/src/bindings.cpp @@ -1,5 +1,6 @@ #include #include "math_operations.h" +#include "renderer3d.h" namespace py = pybind11; @@ -15,12 +16,25 @@ PYBIND11_MODULE(rc_car_cpp, m) { py::arg("x2"), py::arg("y2"), py::arg("z2"), "Calculate angle between two 3D vectors in radians") .def_static("normalize_vector", - [](double x, double y, double z) { - rc_car::MathOperations::normalizeVector(x, y, z); - return py::make_tuple(x, y, z); - }, - py::arg("x"), py::arg("y"), py::arg("z"), - "Normalize a 3D vector, returns (x, y, z) tuple"); + [](double x, double y, double z) { + rc_car::MathOperations::normalizeVector(x, y, z); + return py::make_tuple(x, y, z); + }, + py::arg("x"), py::arg("y"), py::arg("z"), + "Normalize a 3D vector, returns (x, y, z) tuple"); + + py::class_(m, "Renderer3D") + .def(py::init<>()) + .def("set_clear_color", &rc_car::Renderer3D::setClearColor, + py::arg("r"), py::arg("g"), py::arg("b"), py::arg("a") = 1.0f, + "Set the clear color used for the framebuffer") + .def("render_cube", &rc_car::Renderer3D::renderCube, + py::arg("angle_x_deg") = 25.0f, + py::arg("angle_y_deg") = 35.0f, + py::arg("distance") = 4.0f, + py::arg("width") = 640, + py::arg("height") = 480, + "Render a colored cube in the active OpenGL context"); // Version information m.attr("__version__") = "1.0.0"; diff --git a/cpp/src/renderer3d.cpp b/cpp/src/renderer3d.cpp new file mode 100644 index 0000000..77d8788 --- /dev/null +++ b/cpp/src/renderer3d.cpp @@ -0,0 +1,102 @@ +#include "renderer3d.h" +#include + +#ifdef _WIN32 +#include +#include +#elif defined(__APPLE__) +#include +#else +#include +#endif + +namespace rc_car { + +namespace { +float clamp01(float v) { + return std::max(0.0f, std::min(1.0f, v)); +} +} // namespace + +Renderer3D::Renderer3D() : clearColor_{0.05f, 0.09f, 0.14f, 1.0f} {} + +void Renderer3D::setClearColor(float r, float g, float b, float a) { + clearColor_ = { + clamp01(r), + clamp01(g), + clamp01(b), + clamp01(a) + }; +} + +void Renderer3D::renderCube(float angleXDeg, float angleYDeg, float distance, int width, int height) const { + if (width <= 0 || height <= 0) { + return; + } + + glViewport(0, 0, width, height); + glEnable(GL_DEPTH_TEST); + + glClearColor(clearColor_[0], clearColor_[1], clearColor_[2], clearColor_[3]); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + glMatrixMode(GL_PROJECTION); + glLoadIdentity(); + + const float aspect = static_cast(width) / static_cast(height); + glFrustum(-aspect, aspect, -1.0, 1.0, 1.5, 20.0); + + glMatrixMode(GL_MODELVIEW); + glLoadIdentity(); + glTranslatef(0.0f, 0.0f, -distance); + glRotatef(angleXDeg, 1.0f, 0.0f, 0.0f); + glRotatef(angleYDeg, 0.0f, 1.0f, 0.0f); + + glBegin(GL_QUADS); + + // Front face (red) + glColor3f(0.84f, 0.27f, 0.27f); + glVertex3f(-1.0f, -1.0f, 1.0f); + glVertex3f( 1.0f, -1.0f, 1.0f); + glVertex3f( 1.0f, 1.0f, 1.0f); + glVertex3f(-1.0f, 1.0f, 1.0f); + + // Back face (cyan) + glColor3f(0.16f, 0.67f, 0.84f); + glVertex3f(-1.0f, -1.0f, -1.0f); + glVertex3f(-1.0f, 1.0f, -1.0f); + glVertex3f( 1.0f, 1.0f, -1.0f); + glVertex3f( 1.0f, -1.0f, -1.0f); + + // Left face (green) + glColor3f(0.23f, 0.82f, 0.39f); + glVertex3f(-1.0f, -1.0f, -1.0f); + glVertex3f(-1.0f, -1.0f, 1.0f); + glVertex3f(-1.0f, 1.0f, 1.0f); + glVertex3f(-1.0f, 1.0f, -1.0f); + + // Right face (yellow) + glColor3f(0.96f, 0.82f, 0.26f); + glVertex3f(1.0f, -1.0f, -1.0f); + glVertex3f(1.0f, 1.0f, -1.0f); + glVertex3f(1.0f, 1.0f, 1.0f); + glVertex3f(1.0f, -1.0f, 1.0f); + + // Top face (purple) + glColor3f(0.67f, 0.34f, 0.90f); + glVertex3f(-1.0f, 1.0f, 1.0f); + glVertex3f( 1.0f, 1.0f, 1.0f); + glVertex3f( 1.0f, 1.0f, -1.0f); + glVertex3f(-1.0f, 1.0f, -1.0f); + + // Bottom face (blue) + glColor3f(0.11f, 0.56f, 0.83f); + glVertex3f(-1.0f, -1.0f, 1.0f); + glVertex3f(-1.0f, -1.0f, -1.0f); + glVertex3f( 1.0f, -1.0f, -1.0f); + glVertex3f( 1.0f, -1.0f, 1.0f); + + glEnd(); +} + +} // namespace rc_car diff --git a/src/ui/MainWindow.py b/src/ui/MainWindow.py index b680360..a3abf08 100644 --- a/src/ui/MainWindow.py +++ b/src/ui/MainWindow.py @@ -10,6 +10,7 @@ from ui.TelemetryWindow import VehicleTelemetryWindow from ui.VideoStreamingWindow import VideoStreamingWindow +from ui.VisualizationWindow import VisualizationWindow from ui.UIConsumer import BackendIface from ui.FirmwareUpdateWindow import FirmwareUpdateWindow from ui.theme import make_card @@ -53,6 +54,7 @@ class SidePanel(QFrame): showWelcome = pyqtSignal() # Show welcome showTlm = pyqtSignal() # Show telemetry signal showVideoStream = pyqtSignal() # Show video stream + showVisualizer = pyqtSignal() # Show 3D visualizer def __init__(self, parent=None): super().__init__(parent) @@ -108,6 +110,10 @@ def __init__(self, parent=None): self.btnVideo.setToolTip("Open video stream") self.btnVideo.setIcon(QIcon("icons/video.svg")) + self.btn3d = GlowButton(" 3D View") + self.btn3d.setToolTip("Open 3D visualization") + self.btn3d.setIcon(QIcon("icons/rc-car.png")) + self.btnFw = GlowButton(" Firmware") self.btnFw.setIcon(QIcon("icons/upgrade.svg")) self.btnFw.setToolTip("Upload Firmware") @@ -119,6 +125,7 @@ def __init__(self, parent=None): layout.addWidget(self.btnWelcome) layout.addWidget(self.btnTelem) layout.addWidget(self.btnVideo) + layout.addWidget(self.btn3d) layout.addWidget(self.btnFw) layout.addWidget(self.btnGPS) @@ -157,6 +164,7 @@ def __connectSignals(self) -> None: self.btnWelcome.clicked.connect(lambda: self.showWelcome.emit()) self.btnTelem.clicked.connect(lambda: self.showTlm.emit()) self.btnVideo.clicked.connect(lambda: self.showVideoStream.emit()) + self.btn3d.clicked.connect(lambda: self.showVisualizer.emit()) # ---------------------------- @@ -553,6 +561,7 @@ def __init__(self): self.__tlmWindow = VehicleTelemetryWindow(self) self.__streamWindow = VideoStreamingWindow() self.__fwWindow = FirmwareUpdateWindow(self) + self.__vizWindow = VisualizationWindow(self) # Side panel self.side = SidePanel(self) @@ -587,6 +596,7 @@ def __connectSignals(self): self.side.showWelcome.connect(self.__showWelcome) self.side.showTlm.connect(self.__showTlm) self.side.showVideoStream.connect(self.__showVideo) + self.side.showVisualizer.connect(self.__showVisualizer) self.__welcomeWindow.startRequested.connect(self.__onDiscoveryStart) # When a device is discovered, show it on the welcome window self.__consumer.deviceDiscovered.connect(self.__onDeviceDiscovered) @@ -786,6 +796,11 @@ def __showVideo(self) -> None: self.__setContent(self.__streamWindow) + def __showVisualizer(self) -> None: + """Show the 3D visualization window.""" + self.__setContent(self.__vizWindow) + + def __showFirmware(self) -> None: """Show the firmware update panel in the central area.""" diff --git a/src/ui/VisualizationWindow.py b/src/ui/VisualizationWindow.py new file mode 100644 index 0000000..8b92652 --- /dev/null +++ b/src/ui/VisualizationWindow.py @@ -0,0 +1,129 @@ +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QColor, QPainter, QFont +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget, QFrame +from PyQt6.QtOpenGLWidgets import QOpenGLWidget + +from ui.theme import make_card +from utils import cpp_extensions + + +class _CubeView(QOpenGLWidget): + """ + Lightweight QOpenGLWidget that delegates drawing to the C++ Renderer3D. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._renderer = cpp_extensions.create_renderer3d((0.05, 0.09, 0.14, 1.0)) + self._angle_x = 18.0 + self._angle_y = 32.0 + self._distance = 4.2 + self._spin_enabled = True + + self._timer = QTimer(self) + self._timer.setInterval(16) + self._timer.timeout.connect(self._tick) + self._timer.start() + + def toggle_spin(self): + self._spin_enabled = not self._spin_enabled + if self._spin_enabled: + self._timer.start() + else: + self._timer.stop() + self.update() + + def is_spinning(self) -> bool: + return self._spin_enabled + + def _tick(self): + self._angle_y += 0.8 + self._angle_x += 0.35 + self.update() + + def paintGL(self): # noqa: N802 + if self._renderer is None: + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(10, 17, 28)) + painter.setPen(QColor(200, 210, 220)) + painter.setFont(QFont("Segoe UI", 12, QFont.Weight.DemiBold)) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "C++ renderer unavailable") + painter.end() + return + + self._renderer.render_cube( + self._angle_x, + self._angle_y, + self._distance, + self.width(), + self.height(), + ) + + def resizeGL(self, width, height): # noqa: N802 + # Trigger a repaint at the new size + _ = (width, height) + self.update() + + +class VisualizationWindow(QWidget): + """ + Simple container that hosts the OpenGL view plus small controls. + """ + + def __init__(self, parent=None): + super().__init__(parent) + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(12) + + header = QLabel("3D Vehicle View") + header.setObjectName("header-title") + header.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + header.setStyleSheet("padding-left: 6px;") + + desc = QLabel( + "Rendered via the C++ OpenGL backend. A simple cube stands in for the vehicle model." + ) + desc.setStyleSheet("color: #9ba7b4;") + desc.setWordWrap(True) + + card = QFrame() + make_card(card) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(12, 12, 12, 12) + card_layout.setSpacing(10) + + self._view = _CubeView(card) + self._view.setMinimumHeight(360) + + controls = QHBoxLayout() + controls.setContentsMargins(0, 0, 0, 0) + controls.setSpacing(8) + + self._status = QLabel() + self._status.setStyleSheet("color: #c4ccd8;") + self._update_status() + + self._toggle_btn = QPushButton("Pause rotation") + self._toggle_btn.setFixedHeight(34) + self._toggle_btn.clicked.connect(self._toggle_spin) + + controls.addWidget(self._status, 1) + controls.addWidget(self._toggle_btn, 0, Qt.AlignmentFlag.AlignRight) + + card_layout.addWidget(self._view, 1) + card_layout.addLayout(controls) + + outer.addWidget(header) + outer.addWidget(desc) + outer.addWidget(card, 1) + + def _toggle_spin(self): + self._view.toggle_spin() + self._toggle_btn.setText("Resume rotation" if not self._view.is_spinning() else "Pause rotation") + + def _update_status(self): + if cpp_extensions.is_cpp_available(): + self._status.setText("C++ renderer active (OpenGL)") + else: + self._status.setText("C++ renderer unavailable - showing fallback view") diff --git a/src/utils/cpp_extensions.py b/src/utils/cpp_extensions.py index a0ce47a..0f39025 100644 --- a/src/utils/cpp_extensions.py +++ b/src/utils/cpp_extensions.py @@ -104,6 +104,32 @@ def normalize_vector(x, y, z): return (x, y, z) +def create_renderer3d(clear_color=None): + """ + Create a C++ backed 3D renderer if the extension module is available. + + Args: + clear_color (tuple|list|None): Optional RGBA tuple to set the clear color. + + Returns: + Renderer3D instance or None when the C++ module is unavailable. + """ + if not _cpp_module_available: + logging.warning("C++ module not available - 3D renderer disabled") + return None + + try: + renderer = _rc_car_cpp.Renderer3D() + if clear_color is not None and len(clear_color) >= 3: + r, g, b = clear_color[:3] + a = clear_color[3] if len(clear_color) > 3 else 1.0 + renderer.set_clear_color(float(r), float(g), float(b), float(a)) + return renderer + except Exception as exc: # pragma: no cover - safety net + logging.error(f"Failed to create Renderer3D from C++ module: {exc}") + return None + + # Example usage if __name__ == "__main__": print("C++ Extensions Integration")