OpenSceneGraph: Penanganan Acara

gambar

Pendahuluan


Salah satu fitur dari bahasa C ++ yang sering dikritik adalah kurangnya mekanisme pemrosesan acara dalam standar. Sementara itu, mekanisme ini adalah salah satu cara utama interaksi beberapa komponen perangkat lunak dengan komponen perangkat lunak dan perangkat keras lainnya, dan diimplementasikan pada tingkat OS tertentu. Secara alami, setiap platform memiliki nuansa sendiri dalam mengimplementasikan mekanisme yang dijelaskan.

Sehubungan dengan semua hal di atas, ketika berkembang dalam C ++, ada kebutuhan untuk mengimplementasikan pemrosesan acara dengan satu atau lain cara, diselesaikan dengan menggunakan perpustakaan dan kerangka kerja pihak ketiga. Kerangka kerja Qt yang terkenal menyediakan mekanisme untuk sinyal dan slot, yang memungkinkan mengatur interaksi kelas yang diwarisi dari QObject. Implementasi acara juga hadir di perpustakaan boost. Dan tentu saja, mesin OpenSceneGraph tidak dapat melakukan tanpa "sepeda" sendiri, aplikasi yang akan dibahas dalam artikel.

OSG adalah perpustakaan grafik abstrak. Di satu sisi, itu abstrak dari antarmuka prosedural OpenGL, menyediakan pengembang dengan seperangkat kelas yang merangkum seluruh mekanika OpneGL API. Di sisi lain, itu juga abstrak dari antarmuka pengguna grafis tertentu, karena pendekatan pelaksanaannya berbeda untuk platform yang berbeda dan memiliki fitur bahkan dalam platform yang sama (MFC, Qt, .Net untuk Windows, misalnya).

Terlepas dari platform, dari sudut pandang aplikasi, interaksi pengguna dengan antarmuka grafis bermuara pada generasi urutan peristiwa oleh elemen-elemennya, yang kemudian diproses di dalam aplikasi. Sebagian besar kerangka kerja grafis menggunakan pendekatan ini, tetapi bahkan dalam platform yang sama mereka, sayangnya, tidak kompatibel satu sama lain.

Untuk alasan ini, OSG menyediakan antarmuka dasarnya sendiri untuk menangani acara widget widget dan input pengguna berdasarkan kelas osgGA :: GUIEventHandler. Handler ini dapat dilampirkan ke viewer dengan memanggil metode addEventHandler () dan dihapus dengan metode removeEventHandler (). Tentu saja, kelas handler beton harus diwarisi dari kelas osgGA :: GUIEventHandler, dan metode handle () harus didefinisikan ulang di dalamnya. Metode ini menerima dua argumen: osgGA :: GUIEventAdapter, yang berisi antrian acara dari GUI dan osg :: GUIActionAdepter, digunakan untuk umpan balik. Khas dalam definisi adalah desain seperti itu

bool handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdepter &aa) { //        } 

Parameter osgGA :: GUIActionAdapter memungkinkan pengembang untuk meminta GUI untuk mengambil tindakan sebagai tanggapan terhadap acara tersebut. Dalam kebanyakan kasus, penampil dipengaruhi melalui parameter ini, sebuah penunjuk yang dapat diperoleh dengan konversi penunjuk dinamis

 osgViewer::Viewer* viewer = dynamic_cast<osgViewer::Viewer *>(&aa); 

1. Penanganan acara keyboard dan mouse


Kelas osgGA :: GUIEventAdapter () mengelola semua jenis acara yang didukung oleh OSG, menyediakan data untuk mengatur dan mengambil parameternya. Metode getEventType () mengembalikan acara GUI saat ini yang terkandung dalam antrian acara. Setiap kali, mengganti metode handler () dari handler, saat memanggil metode ini, Anda harus menggunakan pengambil ini untuk menerima event dan menentukan jenisnya.

Tabel berikut menjelaskan semua acara yang tersedia.

Jenis acaraDeskripsiMetode Pengambilan Data Kejadian
PUSH / RELEASE / DOUBLECLICKKlik / Lepaskan dan klik dua kali tombol mousegetX (), getY () - mendapatkan posisi kursor. getButton () - kode pada tombol yang ditekan (LEFT_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON, MIDDLE_MOUSE_BUTTON
ScrolBergulir roda mousegetScrollingMotion () - mengembalikan SCROOL_UP, SCROLL_DOWN, SCROLL_LEFT, SCROLL_RIGHT
DRAGSeret mousegetX (), getY () - posisi kursor; getButtonMask () - nilai yang mirip dengan getButton ()
PINDAHKANGerakan mousegetX (), getY () - posisi kursor
KEYDOWN / KEYUPMenekan / melepaskan tombol pada keyboardgetKey () - kode ASCII dari tombol yang ditekan atau nilai enumerator Key_Symbol (misalnya, KEY_BackSpace)
BINGKAIAcara dihasilkan saat merender bingkaitidak ada input
PENGGUNAAcara Buatan PenggunagetUserDataPointer () - mengembalikan pointer ke buffer data pengguna (buffer dikontrol oleh smart pointer)

Ada juga metode getModKeyMask () untuk mengambil informasi tentang kunci pengubah yang ditekan (mengembalikan nilai dari bentuk MODKEY_CTRL, MODKEY_SHIFT, MODKEY_ALT, dan sebagainya), memungkinkan Anda untuk memproses kombinasi tombol yang menggunakan pengubah

 if (ea.getModKeyMask() == osgGA::GUIEventAdapter::MODKEY_CTRL) { //    Ctrl } 

Ingatlah bahwa metode penyetel seperti setX (), setY (), setEventType (), dll. tidak digunakan dalam handler handle (). Mereka dipanggil oleh sistem windowing grafis tingkat rendah OSG untuk mengantri acara.

2. Kami mengontrol cessna dari keyboard


Kita sudah tahu bagaimana mengubah objek adegan melalui kelas osg :: MatrixTransform. Kami memeriksa berbagai jenis animasi menggunakan kelas osg :: AnimationPath dan osg :: Animation. Tetapi untuk interaktivitas suatu aplikasi (misalnya, sebuah game), animasi dan transformasi jelas tidak cukup. Langkah selanjutnya adalah mengontrol posisi objek di atas panggung dari perangkat input pengguna. Mari kita coba kencangkan manajemen ke cessna kita tercinta.

Contoh keyboard
main.h

 #ifndef MAIN_H #define MAIN_H #include <osg/MatrixTransform> #include <osgDB/ReadFile> #include <osgGA/GUIEventHandler> #include <osgViewer/Viewer> #endif 

main.cpp

 #include "main.h" //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ class ModelController : public osgGA::GUIEventHandler { public: ModelController( osg::MatrixTransform *node ) : _model(node) {} virtual bool handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa); protected: osg::ref_ptr<osg::MatrixTransform> _model; }; //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ bool ModelController::handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa) { (void) aa; if (!_model.valid()) return false; osg::Matrix matrix = _model->getMatrix(); switch (ea.getEventType()) { case osgGA::GUIEventAdapter::KEYDOWN: switch (ea.getKey()) { case 'a': case 'A': matrix *= osg::Matrix::rotate(-0.1, osg::Z_AXIS); break; case 'd': case 'D': matrix *= osg::Matrix::rotate( 0.1, osg::Z_AXIS); break; case 'w': case 'W': matrix *= osg::Matrix::rotate(-0.1, osg::X_AXIS); break; case 's': case 'S': matrix *= osg::Matrix::rotate( 0.1, osg::X_AXIS); break; default: break; } _model->setMatrix(matrix); break; default: break; } return true; } //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ int main(int argc, char *argv[]) { (void) argc; (void) argv; osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("../data/cessna.osg"); osg::ref_ptr<osg::MatrixTransform> mt = new osg::MatrixTransform; mt->addChild(model.get()); osg::ref_ptr<osg::Group> root = new osg::Group; root->addChild(mt.get()); osg::ref_ptr<ModelController> mcontrol = new ModelController(mt.get()); osgViewer::Viewer viewer; viewer.addEventHandler(mcontrol.get()); viewer.getCamera()->setViewMatrixAsLookAt( osg::Vec3(0.0f, -100.0f, 0.0f), osg::Vec3(), osg::Z_AXIS ); viewer.getCamera()->setAllowEventFocus(false); viewer.setSceneData(root.get()); return viewer.run(); } 


Untuk mengatasi masalah ini, kami menulis kelas event handler input

 class ModelController : public osgGA::GUIEventHandler { public: ModelController( osg::MatrixTransform *node ) : _model(node) {} virtual bool handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa); protected: osg::ref_ptr<osg::MatrixTransform> _model; }; 

Ketika membangun kelas ini, sebagai parameter, ia meneruskan sebuah pointer ke node transformasi, yang akan kita tindak lanjuti di handler. Metode handler () menangani sendiri didefinisikan ulang sebagai berikut

 bool ModelController::handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa) { (void) aa; if (!_model.valid()) return false; osg::Matrix matrix = _model->getMatrix(); switch (ea.getEventType()) { case osgGA::GUIEventAdapter::KEYDOWN: switch (ea.getKey()) { case 'a': case 'A': matrix *= osg::Matrix::rotate(-0.1, osg::Z_AXIS); break; case 'd': case 'D': matrix *= osg::Matrix::rotate( 0.1, osg::Z_AXIS); break; case 'w': case 'W': matrix *= osg::Matrix::rotate(-0.1, osg::X_AXIS); break; case 's': case 'S': matrix *= osg::Matrix::rotate( 0.1, osg::X_AXIS); break; default: break; } _model->setMatrix(matrix); break; default: break; } return false; } 

Di antara rincian penting dari implementasinya, harus dicatat bahwa kita harus terlebih dahulu mendapatkan matriks transformasi dari simpul yang kita kontrol

 osg::Matrix matrix = _model->getMatrix(); 

Selanjutnya, dua pernyataan switch bersarang () menganalisis jenis peristiwa (keystroke) dan kode tombol yang ditekan. Bergantung pada kode tombol yang ditekan, matriks transformasi saat ini dikalikan dengan matriks rotasi tambahan di sekitar sumbu yang sesuai

 case 'a': case 'A': matrix *= osg::Matrix::rotate(-0.1, osg::Z_AXIS); break; 

- Putar pesawat pada sudut yaw -0.1 radian saat Anda menekan tombol "A".

Setelah memproses penekanan tombol, jangan lupa untuk menerapkan matriks transformasi baru ke node transformasi

 _model->setMatrix(matrix); 

Dalam fungsi utama (), muat model pesawat dan buat simpul transformasi induk untuknya, tambahkan subgraf yang dihasilkan ke simpul akar adegan

 osg::ref_ptr<osg::Node> model = osgDB::readNodeFile("../data/cessna.osg"); osg::ref_ptr<osg::MatrixTransform> mt = new osg::MatrixTransform; mt->addChild(model.get()); osg::ref_ptr<osg::Group> root = new osg::Group; root->addChild(mt.get()); 

Membuat dan menginisialisasi penangan input pengguna

 osg::ref_ptr<ModelController> mcontrol = new ModelController(mt.get()); 

Buat pengunjung dengan menambahkan penangan kami ke sana

 osgViewer::Viewer viewer; viewer.addEventHandler(mcontrol.get()); 

Atur matriks tampilan kamera

 viewer.getCamera()->setViewMatrixAsLookAt( osg::Vec3(0.0f, -100.0f, 0.0f), osg::Vec3(), osg::Z_AXIS ); 

Larang kamera untuk menerima acara dari perangkat input

 viewer.getCamera()->setAllowEventFocus(false); 

Jika ini tidak dilakukan, maka pawang yang tergantung pada kamera akan secara default mencegat semua input pengguna dan mengganggu pawang kita. Kami mengatur data adegan ke penampil dan menjalankannya

 viewer.setSceneData(root.get()); return viewer.run(); 

Sekarang, setelah meluncurkan program, kita akan dapat mengontrol orientasi pesawat di ruang angkasa dengan menekan tombol A, D, W dan S.



Pertanyaan yang menarik adalah apa yang harus dikembalikan oleh metode handle () saat keluar. Jika benar dikembalikan, maka kami menunjukkan OSG, maka kami telah memproses input peristiwa dan pemrosesan lebih lanjut tidak diperlukan. Paling sering, perilaku ini tidak sesuai dengan kita, jadi adalah praktik yang baik untuk mengembalikan false dari handler agar tidak mengganggu pemrosesan acara oleh penangan lain jika mereka melekat pada node lain di tempat kejadian.

3. Penggunaan pengunjung dalam pemrosesan acara


Mirip dengan bagaimana itu diterapkan ketika melintasi grafik adegan ketika memperbaruinya, OSG mendukung panggilan balik untuk menangani peristiwa yang dapat dikaitkan dengan node dan objek geometris. Untuk ini, panggilan ke setEventCallback () dan addEventCallback () digunakan, yang mengambil sebagai parameter penunjuk ke child osg :: NodeCallback. Untuk menerima peristiwa di operator operator (), kami dapat mengonversi pointer yang diteruskan ke pengunjung situs menjadi pointer ke osgGA :: EventVisitor, misalnya seperti ini

 #include <osgGA/EventVisitor> ... void operator()( osg::Node *node, osg::NodeVisitor *nv ) { std::list<osg::ref_ptr<osgGA::GUIEventAdapter>> events; osgGA::EventVisitor *ev = dynamic_cast<osgGA::EventVisitor *>(nv); if (ev) { events = ev->getEvents(); //       } } 

4. Pembuatan dan pemrosesan acara khusus


OSG menggunakan antrian acara internal (FIFO). Acara di awal antrian diproses dan dihapus darinya. Acara yang baru dibuat ditempatkan di akhir antrian. Metode handle () dari masing-masing event handler akan dieksekusi sebanyak yang ada dalam antrian. Antrian acara dijelaskan oleh kelas osgGA :: EventQueue, yang, di antara hal-hal lainnya, memungkinkan Anda untuk menempatkan suatu peristiwa dalam antrian kapan saja dengan memanggil metode addEvent (). Argumen untuk metode ini adalah pointer ke osgGA :: GUIEventAdapter, yang dapat diatur ke perilaku tertentu menggunakan metode setEventType () dan seterusnya.

Salah satu metode dari kelas osgGA :: EventQueue adalah userEvent (), yang menetapkan acara pengguna dengan mengaitkannya dengan data pengguna, sebuah penunjuk yang diteruskan ke parameter tersebut sebagai parameter. Data ini dapat digunakan untuk mewakili setiap acara khusus.

Tidak dapat membuat turunan kejadian sendiri. Instance ini telah dibuat dan dilampirkan pada instance viewer, jadi Anda hanya bisa mendapatkan pointer ke singleton ini

 viewer.getEventQueue()->userEvent(data); 

Data pengguna adalah objek pewaris osg :: Dirujuk, yaitu, Anda dapat membuat penunjuk pintar untuk itu.

Ketika acara khusus diterima, pengembang dapat mengekstrak data darinya dengan memanggil metode getUserData () dan memprosesnya sesuai keinginan.

5. Implementasi pengatur waktu pengguna


Banyak perpustakaan dan kerangka kerja yang menerapkan GUI menyediakan pengembang kelas untuk menerapkan timer yang menghasilkan suatu peristiwa setelah interval waktu tertentu. OSG tidak mengandung sarana reguler untuk menerapkan timer, jadi mari kita coba menerapkan semacam timer sendiri, menggunakan antarmuka untuk membuat acara khusus.

Apa yang bisa kita andalkan saat menyelesaikan masalah ini? Untuk acara periodik tertentu yang terus-menerus dihasilkan oleh render, misalnya, pada FRAME, acara menggambar bingkai berikutnya. Untuk ini kami menggunakan contoh yang sama dengan mengganti model cessna dari normal ke pembakaran.

Contoh pengatur waktu
main.h

 #ifndef MAIN_H #define MAIN_H #include <osg/Switch> #include <osgDB/ReadFile> #include <osgGA/GUIEventHandler> #include <osgViewer/Viewer> #include <iostream> #endif 

main.cpp

 #include "main.h" //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ struct TimerInfo : public osg::Referenced { TimerInfo(unsigned int c) : _count(c) {} unsigned int _count; }; //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ class TimerHandler : public osgGA::GUIEventHandler { public: TimerHandler(osg::Switch *sw, unsigned int interval = 1000) : _switch(sw) , _count(0) , _startTime(0.0) , _interval(interval) , _time(0) { } virtual bool handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa); protected: osg::ref_ptr<osg::Switch> _switch; unsigned int _count; double _startTime; unsigned int _interval; unsigned int _time; }; //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ bool TimerHandler::handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa) { switch (ea.getEventType()) { case osgGA::GUIEventAdapter::FRAME: { osgViewer::Viewer *viewer = dynamic_cast<osgViewer::Viewer *>(&aa); if (!viewer) break; double time = viewer->getFrameStamp()->getReferenceTime(); unsigned int delta = static_cast<unsigned int>( (time - _startTime) * 1000.0); _startTime = time; if ( (_count >= _interval) || (_time == 0) ) { viewer->getEventQueue()->userEvent(new TimerInfo(_time)); _count = 0; } _count += delta; _time += delta; break; } case osgGA::GUIEventAdapter::USER: if (_switch.valid()) { const TimerInfo *ti = dynamic_cast<const TimerInfo *>(ea.getUserData()); std::cout << "Timer event at: " << ti->_count << std::endl; _switch->setValue(0, !_switch->getValue(0)); _switch->setValue(1, !_switch->getValue(1)); } break; default: break; } return false; } //------------------------------------------------------------------------------ // //------------------------------------------------------------------------------ int main(int argc, char *argv[]) { (void) argc; (void) argv; osg::ref_ptr<osg::Node> model1 = osgDB::readNodeFile("../data/cessna.osg"); osg::ref_ptr<osg::Node> model2 = osgDB::readNodeFile("../data/cessnafire.osg"); osg::ref_ptr<osg::Switch> root = new osg::Switch; root->addChild(model1.get(), true); root->addChild(model2.get(), false); osgViewer::Viewer viewer; viewer.setSceneData(root.get()); viewer.addEventHandler(new TimerHandler(root.get(), 1000)); return viewer.run(); } 


Pertama, mari kita tentukan format data yang dikirim dalam pesan pengguna, mendefinisikannya sebagai struktur

 struct TimerInfo : public osg::Referenced { TimerInfo(unsigned int c) : _count(c) {} unsigned int _count; }; 

Parameter _count akan berisi jumlah bilangan bulat milidetik yang berlalu sejak saat program diluncurkan hingga acara penghitung waktu berikutnya diterima. Struktur ini diturunkan dari kelas osg :: Referenced sehingga dapat dikontrol melalui OSG smart pointer. Sekarang buat pengendali acara

 class TimerHandler : public osgGA::GUIEventHandler { public: TimerHandler(osg::Switch *sw, unsigned int interval = 1000) : _switch(sw) , _count(0) , _startTime(0.0) , _interval(interval) , _time(0) { } virtual bool handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa); protected: osg::ref_ptr<osg::Switch> _switch; unsigned int _count; double _startTime; unsigned int _interval; unsigned int _time; }; 

Pawang ini memiliki beberapa anggota tertentu yang dilindungi. Variabel _switch menunjukkan node yang beralih model pesawat terbang; _count - hitungan mundur relatif dari waktu yang berlalu sejak generasi terakhir dari peristiwa pengatur waktu, berfungsi untuk menghitung interval waktu; _startTime - variabel sementara untuk menyimpan hitung mundur sebelumnya, yang dilakukan oleh pemirsa; _time - total waktu program dalam milidetik. Konstruktor kelas menerima switch node sebagai parameter dan, secara opsional, interval waktu yang diperlukan untuk pengatur waktu switching untuk beroperasi.

Di kelas ini, kami menimpa metode handle ()

 bool TimerHandler::handle(const osgGA::GUIEventAdapter &ea, osgGA::GUIActionAdapter &aa) { switch (ea.getEventType()) { case osgGA::GUIEventAdapter::FRAME: { osgViewer::Viewer *viewer = dynamic_cast<osgViewer::Viewer *>(&aa); if (!viewer) break; double time = viewer->getFrameStamp()->getReferenceTime(); unsigned int delta = static_cast<unsigned int>( (time - _startTime) * 1000.0); _startTime = time; if ( (_count >= _interval) || (_time == 0) ) { viewer->getEventQueue()->userEvent(new TimerInfo(_time)); _count = 0; } _count += delta; _time += delta; break; } case osgGA::GUIEventAdapter::USER: if (_switch.valid()) { const TimerInfo *ti = dynamic_cast<const TimerInfo *>(ea.getUserData()); std::cout << "Timer event at: " << ti->_count << std::endl; _switch->setValue(0, !_switch->getValue(0)); _switch->setValue(1, !_switch->getValue(1)); } break; default: break; } return false; } 

Di sini kami menganalisis jenis pesan yang diterima. Jika FRAME, maka tindakan berikut dilakukan:
  1. Dapatkan pointer ke penampil

 osgViewer::Viewer *viewer = dynamic_cast<osgViewer::Viewer *>(&aa); 

  1. Setelah menerima pointer yang benar, baca waktu yang berlalu sejak program dimulai

 double time = viewer->getFrameStamp()->getReferenceTime(); 

menghitung jumlah waktu yang dihabiskan untuk membuat bingkai dalam milidetik

 unsigned int delta = static_cast<unsigned int>( (time - _startTime) * 1000.0); 

dan ingat hitungan waktu saat ini

 _startTime = time; 

Jika nilai penghitung _count melebihi interval waktu yang diperlukan (atau ini adalah panggilan pertama ketika _time masih nol), kami menempatkan pesan pengguna dalam antrian, melewati struktur di atas waktu program dalam milidetik. Penghitung _ jumlah diset ulang ke nol

 if ( (_count >= _interval) || (_time == 0) ) { viewer->getEventQueue()->userEvent(new TimerInfo(_time)); _count = 0; } 

Terlepas dari nilai _count, kita harus meningkatkannya dan _time dengan jumlah penundaan yang diperlukan untuk menggambar bingkai

 _count += delta; _time += delta; 

Ini adalah bagaimana acara pengatur waktu akan dihasilkan. Penanganan acara dilaksanakan sebagai berikut

 case osgGA::GUIEventAdapter::USER: if (_switch.valid()) { const TimerInfo *ti = dynamic_cast<const TimerInfo *>(ea.getUserData()); std::cout << "Timer event at: " << ti->_count << std::endl; _switch->setValue(0, !_switch->getValue(0)); _switch->setValue(1, !_switch->getValue(1)); } break; 

Di sini kita memeriksa validitas pointer ke switching node, kurangi data dari event, memimpin dari struktur TimerInfo, menampilkan konten struktur pada layar dan mengganti status node.

Kode dalam fungsi main () mirip dengan kode pada dua contoh switching sebelumnya, dengan perbedaan bahwa dalam hal ini kita menggantung event handler pada viewer.

 viewer.addEventHandler(new TimerHandler(root.get(), 1000)); 

meneruskan pointer ke simpul akar dan interval switching yang diperlukan dalam milidetik ke konstruktor handler. Menjalankan contoh, kita akan melihat bahwa model beralih pada interval satu detik, dan di konsol kita menemukan output dari waktu di mana switching terjadi

 Timer event at: 0 Timer event at: 1000 Timer event at: 2009 Timer event at: 3017 Timer event at: 4025 Timer event at: 5033 

Acara khusus dapat dibuat kapan saja selama pelaksanaan program, dan tidak hanya ketika acara FRAME diterima, dan ini memberikan mekanisme yang sangat fleksibel untuk bertukar data antara bagian-bagian dari program, memungkinkan untuk memproses sinyal dari perangkat input non-standar, seperti joystick atau sarung tangan VR, misalnya.

Dilanjutkan ...

Source: https://habr.com/ru/post/id438218/


All Articles