Как стать автором
Обновить

OpenSceneGraph: Интеграция с фреймворком Qt

Время на прочтение 15 мин
Количество просмотров 6K


Введение


С одной стороны движок OpenSceneGraph и сам по себе обладает развитой подсистемой управления окнами, обработки событий пользовательского ввода, отправки и приема пользовательских сообщений. Об этом мы довольно подробно поговорили в предыдущих статьях этого цикла. В общем, в сумме с возможностями C++/STL этого вполне достаточно для разработки сколь угодно сложных приложений.

Пример интеграции OSG в приложение, разработанной в QtDesigner. Этот пример будет подробно разобран ниже


С другой стороны, для ускорения разработки на C++ применяются как сторонние библиотеки, расширяющие возможности этого языка (вроде boost), так и целые фреймворки, позволяющие легко и непринужденно разрабатывать кроссплатформенные приложения широкого функционального назначения. Одним из таких фреймворков является ультра популярный Qt. Как бы не ругали Qt за его метаобъектный компилятор и прочие недостатки и неудобства, сила Qt в обширной библиотеке классов, решающей все мыслимые задачи кроссплатформенной разработки, а так же в концепции "сигналы — слоты", реализующей подсистему обмена сообщениями между классами. На сигналах и слотах основаны так же методы взаимодействия приложения с операционной системой, а так же межпроцессное взаимодействие.

И, черт возьми, было бы весьма интересно совместить две технологии: Qt и OSG. Подобную задачу пришлось решать моему коллективу, о чем я уже писал в одной из своих публикаций. Однако, этот вопрос хотелось бы раскрыть немного шире, и данная статья будет как раз на эту тему.

Существует две возможности интеграции OSG и Qt:

  1. Использование сигналов и слотов Qt для взаимодействия объектов в рамках приложения OSG
  2. Интеграция вьювера OSG в графический интерфейс, разрабатываемый на C++/Qt, с применением том числе и дизайнера форм QtDesigner

Первый вариант применим когда не требуется использование элементов GUI, предоставляемых Qt, но требуется обеспечить взаимодействие компонентов приложения, посредством сигналов и слотов. Например, такая потребность возникла у меня, для интеграции OSG-приложения с библиотекой межпроцессного взаимодействия через TCP-сокеты, использующей Qt.

Второй вариант необходим, когда требуется интеграция движка OSG и графическое приложение, разрабатываемое с применением Qt. Нам становятся доступны сигналы и слоты, а кроме них и весь спектр стандартизированных элементов GUI, предоставляемых Qt.

1. Сигналы Qt в оконной системе OSG


Первый пример будет несколько синтетическим: напишем простое приложение OSG с примитивной сценой; создадим два класса, один из которых будет обрабатывать нажатие клавиш, а другой выводить в консоль сообщение о том какая клавиша нажата. При этом обработчик будет формировать сигнал Qt, с сообщением о нажатой клавише в качестве параметра.

Чтобы выполнить интеграцию с Qt достаточно удовлетворить следующим трем условиям

  1. Наследовать взаимодействующие классы от QObject
  2. Организовать цикл обработки сигналов
  3. Создать экземпляр класса QApplication (или QCoreApplication) существующий в памяти в процессе работы приложения

Полный код примера можно увидеть здесь, в моем репозитории OSG-lessons, где собраны все уроки по данному циклу.

Для начала создадим класс, который будет "крутить" очередь обработки сигналов

qt-events.h

#ifndef     QT_EVENTS_H
#define     QT_EVENTS_H

#include    <osgGA/GUIEventHandler>
#include    <QCoreApplication>

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
class QtEventsHandler : public osgGA::GUIEventHandler
{
public:

    QtEventsHandler();

    virtual bool handle(const osgGA::GUIEventAdapter &ea,
                        osgGA::GUIActionAdapter &aa);

protected:


};

#endif // QT_EVENTS_H

qt-events.cpp

#include    "qt-events.h"

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
QtEventsHandler::QtEventsHandler()
{

}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
bool QtEventsHandler::handle(const osgGA::GUIEventAdapter &ea,
                             osgGA::GUIActionAdapter &aa)
{
    Q_UNUSED(aa)

    switch (ea.getEventType())
    {
    case osgGA::GUIEventAdapter::FRAME:
        {
            // Process qt signals and event
            QCoreApplication::processEvents(QEventLoop::AllEvents);

            break;
        }

    default:

        break;
    }

    return false;
}

Все предельно просто — данный класс это стандартный обработчик событий OSG, который при отрисовке кадра инициирует обработку очереди сигналов Qt вызовом

QCoreApplication::processEvents(QEventLoop::AllEvents);

Теперь создадим класс, обрабатывающий клавиатуру, опять таки, использующий механизм встроенный в OSG, но при этом способный послать сигнал Qt. Для этого мы применим преданное анафеме в последнее время множественное наследование

keyhandler.h

#ifndef     KEY_HANDLER_H
#define     KEY_HANDLER_H

#include    <osgGA/GUIEventHandler>
#include    <QObject>

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
class KeyboardHandler : public QObject, public osgGA::GUIEventHandler
{
    Q_OBJECT

public:

    KeyboardHandler(QObject *parent = Q_NULLPTR)
        : QObject(parent)
        , osgGA::GUIEventHandler ()
    {

    }

    bool handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa)
    {
        switch (ea.getEventType())
        {
        case osgGA::GUIEventAdapter::KEYDOWN:

            emit sendMessage("Pressed key " + QString(ea.getKey()));

            break;

        default:

            break;
        }


        return false;
    }

signals:

    void sendMessage(QString msg);

private:
};

#endif // KEY_HANDLER_H

Класс будет обрабатывать сообщение о нажатии клавиши и посылать сигнал с сообщением, содержащим код нажатой клавиши. Принимать этот сигнал будет класс, никак не связанный с OSG, являющийся наследником QObject и содержащий один единственный слот, печатающий сообщение в стандартный поток вывода

receiver.h

#ifndef     RECEIVER_H
#define     RECEIVER_H

#include    <QObject>
#include    <iostream>

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
class Receiver : public QObject
{
    Q_OBJECT

public:

    Receiver(QObject *parent = Q_NULLPTR) : QObject(parent) {}

public slots:

    void printMessage(QString msg)
    {
        std::cout << msg.toStdString() << std::endl;
    }
};

#endif // RECEIVER_H

Теперь соберем всё в кучу, написав приложение OSG

main.h

#ifndef     MAIN_H
#define     MAIN_H

#include    <osgViewer/Viewer>
#include    <osgDB/ReadFile>

#include    <QCoreApplication>

#include    "qt-events.h"

#include    "keyhandler.h"
#include    "receiver.h"

#endif

main.cpp

#include    "main.h"

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    osg::ref_ptr<osg::Node> scene = osgDB::readNodeFile("../data/cessnafire.osg");

    osgViewer::Viewer viewer;
    viewer.setSceneData(scene.get());    
    viewer.addEventHandler(new QtEventsHandler);
    viewer.setUpViewInWindow(0, 0, 1024, 768);

    KeyboardHandler *keyboardHandler = new KeyboardHandler;
    Receiver *receiver = new Receiver;

    QObject::connect(keyboardHandler, &KeyboardHandler::sendMessage,
                     receiver, &Receiver::printMessage);

    viewer.addEventHandler(keyboardHandler);

    return viewer.run();
}

Для начала мы создаем экземпляр класса QCoreApplication.

QCoreApplication app(argc, argv);

Это необходимо для работы описываемой технологии. При этом мы не будем вызывать метод QCoreApplication::exec()! Вместо этого у цикл обработки сигналов будет крутится у нас внутри цикла osgViewer::Viewer::run(), для чего мы создаем и регистрируем соответствующий обработчик

viewer.addEventHandler(new QtEventsHandler);

Создаем экземпляры классов, который будут взаимодействовать посредством сигналов Qt, связывая сигнал одного со слотом другого

KeyboardHandler *keyboardHandler = new KeyboardHandler;
Receiver *receiver = new Receiver;

QObject::connect(keyboardHandler, &KeyboardHandler::sendMessage,
                 receiver, &Receiver::printMessage);

Регистрируем обработчик клавиатуры

viewer.addEventHandler(keyboardHandler);

Все, запускаем вьювер


return viewer.run();

и видим такую картину


Да, пример несколько надуманный, но он иллюстрирует главные принципы интеграции кода, использующего механизмы Qt в приложение, использующее OSG. Эта идея, почерпнутая из книги OpenSceneGraph 3. Cookbook, сэкономила мне и моей команде разработчиков уйму времени и нервов, позволив использовать в проекте на OSG отлаженный и стандартизированный в пределах нашей кодовой базы модуль, основанный на Qt.

А что если мы таки хотим использовать OSG внутри GUI-приложения на Qt?

2. Библиотека osgQt


osgQt — библиотека интеграции, предназначенная для:

  1. Встраивания трехмерной сцены, реализованной на OSG в графический интерфейс приложения, разрабатываемого на Qt
  2. Встраивания виджетов Qt на поверхности трехмерной геометрии внутри сцены OSG. Да, вы не ослышались — виджеты Qt могут преспокойно работать внутри виртуального мира. Когда-нибудь я обязательно это продемонстрирую

С этой библиотекой были определенные проблемы, которые удалось преодолеть путем внимательного изучения прилагаемых к ней примеров и чтения уже упомянутой OpenSceneGraph 3. Cookbook

Библиотеку следует собрать, и процесс этот аналогичен сборке самого движка, подробно описан в самой первой статье цикла. Единственным замечанием будет то, что -DCMAKE_INSTALL_PREFIX следует выбрать тем же самым, что был указан при сборке движка — так osgQt установится рядом с движком, и её будет удобно пользоваться при разработке.

3. Интеграция osgViewer::Viewer в Qt GUI


Следующий пример будет довольно полезным. Мы напишем просмотрщик, позволяющий загружать модели формата *.osg, используя стандартные элементы управления Qt. Причем для разработки графического интерфейса используем QtDeisgner.

Создадим новый проект типа «Приложение Qt Widgets»



При этом будет сгенерировано главное окно приложения с заготовкой меню, тулбаром и статусной строкой. В QtDesigner добавим на это окно компонент QFrame



В этот фрейм мы поместим вьювер OSG. Вьювер OSG по сути будет являтся виджетом Qt, для его реализации напишем класс QViewerWidget. Полные исходники помещу по спойлер, дабы не замыливать изложение простынями кода

qviewerwidget.h

#ifndef     QVIEWER_WIDGET_H
#define     QVIEWER_WIDGET_H

#include    <QWidget>

#include    <osgViewer/Viewer>
#include    <osgQt/GraphicsWindowQt>

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
class QViewerWidget : public QWidget
{
public:

    QViewerWidget(const QRect &geometry);

    virtual ~QViewerWidget();

    osg::Group *getScene();

    osgViewer::Viewer *getViewer();

protected:

    osg::ref_ptr<osg::Group> scene;

    osgViewer::Viewer   viewer;

private:

    osgQt::GraphicsWindowQt *createGraphicsWindow(const QRect &geometry);

    void initCamera(const QRect &geometry);

    void paintEvent(QPaintEvent *);
};

#endif // QVIEWER_WIDGET_H


qviewerwidget.cpp

include    "qviewerwidget.h"

#include    <osgViewer/ViewerEventHandlers>
#include    <osgGA/TrackballManipulator>

#include    <QGridLayout>

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
QViewerWidget::QViewerWidget(const QRect &geometry)
    : QWidget()
    , scene(new osg::Group)
{
    initCamera(geometry);

    viewer.setSceneData(scene);
    viewer.addEventHandler(new osgViewer::StatsHandler);
    viewer.setCameraManipulator(new osgGA::TrackballManipulator);
    viewer.setThreadingModel(osgViewer::Viewer::SingleThreaded);

    osgQt::GraphicsWindowQt *gw = static_cast<osgQt::GraphicsWindowQt *>(viewer.getCamera()->getGraphicsContext());

    QGridLayout *layout = new QGridLayout;

    if (layout != Q_NULLPTR)
    {
        layout->addWidget(gw->getGLWidget());
        this->setLayout(layout);
    }
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
QViewerWidget::~QViewerWidget()
{

}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
osg::Group *QViewerWidget::getScene()
{
    return scene.get();
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
osgViewer::Viewer *QViewerWidget::getViewer()
{
    return &viewer;
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
osgQt::GraphicsWindowQt *QViewerWidget::createGraphicsWindow(const QRect &geometry)
{
    osg::DisplaySettings *ds = osg::DisplaySettings::instance().get();

    osg::ref_ptr<osg::GraphicsContext::Traits> traits = new osg::GraphicsContext::Traits;
    traits->windowName = "";
    traits->windowDecoration = false;
    traits->x = geometry.x();
    traits->y = geometry.y();
    traits->width = geometry.width();
    traits->height = geometry.height();

    if (traits->height == 0) traits->height = 1;

    traits->doubleBuffer = true;
    traits->alpha = ds->getMinimumNumAlphaBits();
    traits->stencil = ds->getMinimumNumStencilBits();
    traits->sampleBuffers = ds->getMultiSamples();
    traits->samples = ds->getNumMultiSamples();

    return new osgQt::GraphicsWindowQt(traits.get());
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
void QViewerWidget::initCamera(const QRect &geometry)
{
    osg::Camera *camera = viewer.getCamera();

    osg::ref_ptr<osgQt::GraphicsWindowQt> gw = createGraphicsWindow(geometry);
    gw->setTouchEventsEnabled(true);
    camera->setGraphicsContext(gw.get());

    const osg::GraphicsContext::Traits *traits = gw->getTraits();
    camera->setClearColor(osg::Vec4(0.7f, 0.7f, 0.7f, 1.0f));

    camera->setViewport(0, 0, traits->width, traits->height);

    double aspect = static_cast<double>(traits->width) / static_cast<double>(traits->height);
    camera->setProjectionMatrixAsPerspective(30.0, aspect, 1.0, 1000.0);
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
void QViewerWidget::paintEvent(QPaintEvent *)
{
    viewer.frame();
}


Основная идея реализации состоит в использовании класса osgQt::GraphicsWindow, создающий графическое окно, базирующее на классе QGLWidget. Для создания этого окна служит метод


osgQt::GraphicsWindowQt *QViewerWidget::createGraphicsWindow(const QRect &geometry)
{
    osg::DisplaySettings *ds = osg::DisplaySettings::instance().get();

    osg::ref_ptr<osg::GraphicsContext::Traits> traits = new osg::GraphicsContext::Traits;
    traits->windowName = "";
    traits->windowDecoration = false;
    traits->x = geometry.x();
    traits->y = geometry.y();
    traits->width = geometry.width();
    traits->height = geometry.height();

    if (traits->height == 0) traits->height = 1;

    traits->doubleBuffer = true;
    traits->alpha = ds->getMinimumNumAlphaBits();
    traits->stencil = ds->getMinimumNumStencilBits();
    traits->sampleBuffers = ds->getMultiSamples();
    traits->samples = ds->getNumMultiSamples();

    return new osgQt::GraphicsWindowQt(traits.get());
}

Окно настраивается в соответствии с параметрами геометрии, переданными на вход, и требуемыми настройками трехмерного рендера OSG. Возвращаемый указатель — это графический контекст OSG, который следует передать в камеру. Поэтому следующим шагом выполняем инициализацию камеры


void QViewerWidget::initCamera(const QRect &geometry)
{
    osg::Camera *camera = viewer.getCamera();

    osg::ref_ptr<osgQt::GraphicsWindowQt> gw = createGraphicsWindow(geometry);
    gw->setTouchEventsEnabled(true);
    camera->setGraphicsContext(gw.get());

    const osg::GraphicsContext::Traits *traits = gw->getTraits();
    camera->setClearColor(osg::Vec4(0.7f, 0.7f, 0.7f, 1.0f));

    camera->setViewport(0, 0, traits->width, traits->height);

    double aspect = static_cast<double>(traits->width) / static_cast<double>(traits->height);
    camera->setProjectionMatrixAsPerspective(30.0, aspect, 1.0, 1000.0);
}

Собственно вызов


camera->setGraphicsContext(gw.get());

и передает камере требуемый контекст, связанный с виджетом QGLWidget. Всю рутину по созданию виджета помещаем в конструктор класса


QViewerWidget::QViewerWidget(const QRect &geometry)
    : QWidget()
    , scene(new osg::Group)
{
    initCamera(geometry);

    viewer.setSceneData(scene);
    viewer.addEventHandler(new osgViewer::StatsHandler);
    viewer.setCameraManipulator(new osgGA::TrackballManipulator);
    viewer.setThreadingModel(osgViewer::Viewer::SingleThreaded);

    osgQt::GraphicsWindowQt *gw = static_cast<osgQt::GraphicsWindowQt *>(viewer.getCamera()->getGraphicsContext());

    QGridLayout *layout = new QGridLayout;

    if (layout != Q_NULLPTR)
    {
        layout->addWidget(gw->getGLWidget());
        this->setLayout(layout);
    }
}

Здесь мы настраиваем вьювер и особое внимание обращаем на вызов


viewer.setThreadingModel(osgViewer::Viewer::SingleThreaded);

переключающий вьювер в однопоточный режим. Это необходимая мера при интеграции OSG в Qt, так как в некоторых дистрибутивах Linux программа будет вываливаться в сегфолт при использовании многопоточного рендеринга, применяемого OSG по умолчанию. Причины этого требуют отдельного разбора полетов, поэтому идем дальше, и обращаем внимание на этот код


osgQt::GraphicsWindowQt *gw = static_cast<osgQt::GraphicsWindowQt *>(viewer.getCamera()->getGraphicsContext());

QGridLayout *layout = new QGridLayout;

if (layout != Q_NULLPTR)
{
     layout->addWidget(gw->getGLWidget());
     this->setLayout(layout);
}

в котором мы создаем слой, помешая в него QGLWidget, возвращаемый из графического контекста камеры, преобразованного к указателю osgQt::GraphicsWindows. Созданный слой мы добавляем к нашему виджету QViewerWidget вызовом


this->setLayout(layout);

Для того чтобы наш виджет, а вместе с ним и сцена обновлялись при обновлении окна, необходимо переопределить обработчик события QPaintEvent


void QViewerWidget::paintEvent(QPaintEvent *)
{
    viewer.frame();
}

в котором мы инициируем отрисовку кадра вызовом метода osgViewer::Viewer::frame().

Ок, код нашего виджета готов, теперь встраиваем его в фрейм, расположенный на форме. Для этого в конструкторе класса MainWindow пишем такой код


MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , qviewer(Q_NULLPTR)
{
    ui->setupUi(this);

    QGridLayout *layout = new QGridLayout;
    qviewer = new QViewerWidget(QRect(0, 0, ui->frame->width(), ui->frame->height()));
    layout->addWidget(qviewer);
    ui->frame->setLayout(layout);
    this->setCentralWidget(ui->frame);

    connect(&timer, &QTimer::timeout, this, &MainWindow::update);
    timer.start(40);

    connect(ui->actionQuit, &QAction::triggered, this, &MainWindow::quit);
    connect(ui->actionClean, &QAction::triggered, this, &MainWindow::clean);
    connect(ui->actionOpen, &QAction::triggered, this, &MainWindow::open);

    this->setWindowTitle("QViewerWidget example");
}

а точнее, нас пока интересует эта его часть


QGridLayout *layout = new QGridLayout;
qviewer = new QViewerWidget(QRect(0, 0, ui->frame->width(), ui->frame->height()));
layout->addWidget(qviewer);
ui->frame->setLayout(layout);
this->setCentralWidget(ui->frame);

где мы создаем слой, создаем наши виджет, с размерами равными размеру фрейма, добавляем созданный виджет в слой, а слой прикрепляем к фрейму. И, чтобы не заморачиваться в этом примере с компоновкой, растягиваем фрейм на всю клиентскую область окна, назначая его центральным виджетом.

Для выполнение рендеринга следует организовать периодическое обновление окна по таймеру. Для этого создаем таймер с интервалом в 40 миллисекунд (25 кадров в секунду) и связываем его сигнал timeout со слотом обновления окна. Я делаю это так, используя синтаксис Qt5


connect(&timer, &QTimer::timeout, this, &MainWindow::update);
timer.start(40);

предварительно определив слот update для класса окна таким способом


void MainWindow::update()
{
    QMainWindow::update(this->geometry());
}

Почему так, ведь можно напрямую связать сигнал таймера со слотом QMainWindow::update таким образом, как это показывают в большинстве примеров использования osgQt

connect(&timer, SIGNAL(timeout), this, SLOT(update));

Дело в том, что синтаксис с макросами SIGNAL() и SLOT() объявлен устаревшим, и в предверии перехода на Qt6 от него нужно отказаться. При этом, класс QMainWindow не имеет перегрузки слота update() без параметров, что вызовет ошибку на вызове связывания при компиляции. Для этого пришлось определить свой слот update() без параметров, вызвав в нем базовый QMainWindow::update() с передачей туда клиентской области окна.

Дописав до этого места и запустив программу мы получим уже некий результат



Нажимая «S» мы можем активировать монитор статистики OSG и убедится, что наш виджет работает как надо, рисуя пустую сцену.

Какой-такой монитор статистики?
Дабы не перегружать статью напишу об этом тут. В OSG есть встроенный монитор, отображающий статистику работы движка в реальном времени. Для его добавления во вьювер подключаем заголовочный файл


#include    <osgViewer/ViewerEventHandlers>

и добавляем обработчик к вьюверу


viewer.addEventHandler(new osgViewer::StatsHandler);

после чего в любой момент по нажатию «S» вывести на экран много полезной информации.

4. Завершаем наш просмотрщик: добавляем меню


В дизайнере форм настраиваем меню, применяя «мышеориентированное» программирование (к которому я равнодушен, но да, порой удобно). В конце концов мы получим что-то вроде этого



Теперь мы заведем соотвествующие слоты-обработчики, позволяющие загрузить модель по пути, выбранному из диалогового окна, очистить сцену и выйти из приложения

Код обработчиков меню

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
void MainWindow::open()
{
    osg::Group *scene = qviewer->getScene();

    if (scene == nullptr)
        return;

    QString path = QFileDialog::getOpenFileName(Q_NULLPTR,
                                                tr("Open model file"),
                                                "./",
                                                tr("OpenSceneGraph (*.osg *.osgt *.osgb *.ivi)"));

    if (path.isEmpty())
        return;

    scene->removeChildren(0, scene->getNumChildren());

    osg::ref_ptr<osg::Node> model = osgDB::readNodeFile(path.toStdString());
    scene->addChild(model.get());
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
void MainWindow::clean()
{
    osg::Group *scene = qviewer->getScene();

    if (scene == nullptr)
        return;

    scene->removeChildren(0, scene->getNumChildren());
}

//------------------------------------------------------------------------------
//
//------------------------------------------------------------------------------
void MainWindow::quit()
{
    QApplication::quit();
}


После этого мы получим очень удобны просмотрщик моделей формата *.osg.



Демонстрация его работы приведена на видео в начале статьи. Полные исходники этого примера доступны здесь

Заключение


Как мы убедились, интеграция OSG и Qt не представляет собой особой сложности ни в понимании, ни в реализации. Это отличное подспорье для создания кроссплатформенных приложений для технической визуализации, а возможно и игр.

Эта статья открывает продолжение цикла об OSG, где будут излагаться сложные приемы разработки. Думаю, она вышла удачной. Благодарю за внимание и до новых встреч!
Теги:
Хабы:
+17
Комментарии 0
Комментарии Комментировать

Публикации

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн