Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26a207d61e | |||
| f43c22d636 | |||
| 94dfe682c6 | |||
| 9f599a564e | |||
| 7eb973ee99 | |||
| 725b4a7a6e |
@@ -1,10 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "SkyVue",
|
"name": "skyvue",
|
||||||
"productName": "SkyVue",
|
"productName": "SkyVue",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "My Electron application description",
|
"description": "Визуальное планирование полёта дрона и генерация Python-кода для Clover/ROS",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"homepage": "https://github.com/IliaMiheev/probody",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/IliaMiheev/probody.git"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
@@ -12,8 +17,8 @@
|
|||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "echo \"No linting configured\""
|
"lint": "echo \"No linting configured\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": ["drone", "clover", "ros", "electron", "flight-planning"],
|
||||||
"author": "Student",
|
"author": "Clovers (https://github.com/IliaMiheev/probody)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.11.1",
|
"@electron-forge/cli": "^7.11.1",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="navigation-wrapper">
|
<div class="navigation-wrapper">
|
||||||
<button class="burger-btn" @click="toggleMenu" :class="{ 'is-active': isOpen }" aria-label="Menu">
|
<button class="burger-btn" @click="toggleMenu" aria-label="Menu">
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
@@ -156,10 +156,6 @@ h1 {
|
|||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-list-item.is-active {
|
|
||||||
background-color: transparent !important;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-content button{
|
.menu-content button{
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -172,18 +168,6 @@ h1 {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active .line:nth-child(1) {
|
|
||||||
transform: translateY(6px) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-active .line:nth-child(2) {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-active .line:nth-child(3) {
|
|
||||||
transform: translateY(-6px) rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-panel {
|
.side-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { publicPath } from '@/shared/publicPath'
|
||||||
|
|
||||||
const APP_LOGO = '/images/drone.png'
|
const APP_LOGO = 'images/drone.png'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
@@ -24,7 +25,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineEmits(['click'])
|
defineEmits(['click'])
|
||||||
|
|
||||||
const imageSrc = computed(() => props.image || APP_LOGO)
|
const imageSrc = computed(() => publicPath(props.image || APP_LOGO))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -55,20 +56,22 @@ const imageSrc = computed(() => props.image || APP_LOGO)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
padding-left: 15px;
|
||||||
margin: 8px 12px;
|
margin: 8px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background 0.2s ease, padding-left 0.25s ease;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card:hover {
|
.list-card:hover {
|
||||||
background: rgba(35, 170, 227, 0.2);
|
background: rgb(31, 170, 230);
|
||||||
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card--active {
|
.list-card--active {
|
||||||
background: linear-gradient(135deg, #23aae3, #1a6d8f);
|
background:rgb(29, 149, 201);
|
||||||
box-shadow: 0 4px 15px rgba(35, 170, 227, 0.3);
|
box-shadow: 0 4px 15px rgba(35, 170, 227, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
<script setup>
|
|
||||||
</script>
|
|
||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
<title/>
|
<title/>
|
||||||
@@ -17,10 +15,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cls-1{
|
.cls-1 {
|
||||||
fill:none;
|
fill: none;
|
||||||
}
|
|
||||||
#Layer_2 {
|
|
||||||
color: black;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const numbers = ref([])
|
||||||
|
const clickCount = ref(1)
|
||||||
|
const listOfTrips = ref([])
|
||||||
|
|
||||||
|
function createEmptyGrid() {
|
||||||
|
return Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
variable: [],
|
||||||
|
x: i % 10,
|
||||||
|
y: 9 - Math.floor(i / 10),
|
||||||
|
index: i,
|
||||||
|
isPlatform: false,
|
||||||
|
isQR: false,
|
||||||
|
BgImg: 'images/ArUco-marker.jpg',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGridInitialized() {
|
||||||
|
if (numbers.value.length === 0) {
|
||||||
|
numbers.value = createEmptyGrid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlannerState() {
|
||||||
|
ensureGridInitialized()
|
||||||
|
|
||||||
|
return {
|
||||||
|
numbers,
|
||||||
|
clickCount,
|
||||||
|
listOfTrips,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,92 +1,112 @@
|
|||||||
const rawTerms = [
|
export const termsItems = [
|
||||||
{
|
{
|
||||||
|
id: 1,
|
||||||
title: 'ROS',
|
title: 'ROS',
|
||||||
body: 'ROS (Robot Operating System) — экосистема для программирования роботов. В приложении через ROS дрон получает команды, передает телеметрию и данные с камеры.',
|
subtitle: 'Экосистема',
|
||||||
image: '/images/terms/ROS.webp',
|
image: '/images/terms/ROS.webp',
|
||||||
|
description: 'ROS (Robot Operating System) — экосистема для программирования роботов и дронов. Несмотря на слово «Operating System», это не операционная система, а набор библиотек, инструментов и правил, по которым разные программы робота обмениваются данными.\n\nВ приложении SkyVue сгенерированный Python-код работает внутри ROS: дрон получает команды полёта, передаёт телеметрию (координаты, высоту) и данные с камеры. Без ROS отдельные части системы — камера, контроллер полёта, ваш скрипт — не смогли бы «разговаривать» друг с другом.\n\nОсновные элементы ROS:\n• Ноды (Node) — отдельные программы, каждая выполняет свою задачу\n• Топики (Topic) — каналы для непрерывной передачи данных (изображение, телеметрия)\n• Сервисы (Service) — вызов функции с немедленным ответом (команда navigate, запрос get_telemetry)\n• Сообщения (Message) — формат данных, который передаётся в топиках и сервисах\n\nНа платформе Clover ROS уже настроен: камера, полётный контроллер и симулятор Gazebo подключены и готовы к работе. Вам остаётся написать логику в одной ноде — приложение делает это автоматически.',
|
||||||
hrefToDoc: 'https://www.ros.org/',
|
hrefToDoc: 'https://www.ros.org/',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748',
|
hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 2,
|
||||||
title: 'OpenCV',
|
title: 'OpenCV',
|
||||||
body: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения. С её помощью дрон распознает цвета платформ и считывает QR-коды.',
|
subtitle: 'Компьютерное зрение',
|
||||||
image: '/images/terms/openCV.webp',
|
image: '/images/terms/openCV.webp',
|
||||||
|
description: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения с открытым исходным кодом. Она умеет обрабатывать изображения и видео: находить объекты, определять цвета, считывать QR-коды и ArUco-маркеры.\n\nЭто «глаза и мозг» для обработки картинки с камеры дрона. Камера сама по себе только передаёт пиксели — OpenCV превращает их в полезную информацию: «перед нами красная платформа» или «в QR-коде написано число 42».\n\nЧто OpenCV делает в заданиях с Clover:\n• Распознаёт цвет платформы (красный, зелёный, синий) по HSV-диапазону\n• Считывает QR-коды с помощью cv2.QRCodeDetector\n• Обрабатывает кадры с камеры в реальном времени через cv2.cvtColor, cv2.inRange и другие функции\n• Работает и в симуляторе Gazebo, и на реальном дроне — код один и тот же\n\nOpenCV — стандарт индустрии: её используют в робототехнике, автономных машинах и системах видеонаблюдения. В Python подключается одной строкой: import cv2.',
|
||||||
hrefToDoc: 'https://opencv.org/',
|
hrefToDoc: 'https://opencv.org/',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893',
|
hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 3,
|
||||||
title: 'navigate_wait',
|
title: 'navigate_wait',
|
||||||
body: 'Navigate_wait — функция из библиотеки Clover. Отправляет дрон в точку и ждёт, пока он туда долетит, и только потом выполняет следующую команду. Это важно, потому что обычная команда navigate не ждет: дрон ещё летит, а скрипт уже бежит дальше — из-за этого следующие команды могут выполняться раньше времени.',
|
subtitle: 'Clover',
|
||||||
|
image: '/images/terms/navigate_wait.jpg',
|
||||||
|
description: 'navigate_wait — функция из библиотеки Clover (clover.srv), которая отправляет дрон в заданную точку и ждёт, пока он туда долетит. Только после прилёта выполняется следующая строка кода.\n\nЭто «команда с терпением»: скрипт не бежит дальше, пока дрон не завершит перелёт.\n\nЧем отличается от обычного navigate:\n• navigate — отправляет команду и сразу идёт дальше. Дрон ещё летит, а скрипт уже выполняет следующие действия\n• navigate_wait — блокирует выполнение до прилёта. Следующая команда гарантированно выполнится в нужной точке\n\nКогда использовать navigate_wait:\n• Перед считыванием QR-кода или определением цвета платформы — дрон должен зависнуть над целью\n• При последовательных перемещениях, где порядок важен (сначала точка A, потом точка B)\n• Когда нужно дождаться завершения манёвра перед следующим действием\n\nПараметры: x, y, z (координаты), frame_id (система координат, например body или aruco_map), speed (скорость), auto_arm (автовооружение). В SkyVue эти параметры подставляются из блоков траектории на карте.',
|
||||||
hrefToDoc: 'https://clover.coex.tech/ru/snippets.html',
|
hrefToDoc: 'https://clover.coex.tech/ru/snippets.html',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803',
|
hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 4,
|
||||||
title: 'Нода (Node)',
|
title: 'Нода (Node)',
|
||||||
body: 'Нода (Node/узел) в ROS - исполняемая программа, которая выполняет одну конкретную задачу. В сгенерированном коде вся логика полета и обработки камеры находится внутри одной ноды. Это позволяет запускать, останавливать и отлаживать программу как единое целое. ',
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/node.jpg',
|
||||||
|
description: 'Нода (Node, узел) в ROS — исполняемая программа, которая выполняет одну конкретную задачу и общается с другими нодами через топики и сервисы.\n\nЭто «отдельный работник» в команде робота: один следит за камерой, другой управляет полётом, третий обрабатывает изображение. Каждый работает независимо, но координируется через ROS.\n\nКак это выглядит в сгенерированном коде:\n• Вся логика полёта и обработки камеры находится внутри одной ноды\n• В начале файла — rospy.init_node(\'flight\'), который регистрирует программу в ROS\n• Внутри ноды создаются подписчики (на камеру, телеметрию) и вызываются сервисы (navigate, get_telemetry)\n\nЗачем одна нода, а не несколько:\n• Проще отлаживать — весь код в одном месте\n• Легче запускать: rosrun clover blockly\n• Удобно для учебных заданий, где логика невелика\n\nВ реальных проектах нод может быть много: отдельная нода для камеры, для навигации, для логирования. Но принцип тот же — каждая нода = одна программа с чёткой задачей.',
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758',
|
hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 5,
|
||||||
title: 'Топик (Topic)',
|
title: 'Топик (Topic)',
|
||||||
body: 'Топик (Topic) в ROS — канал обмена данными. Например, камера публикует изображение в топик, а ваш скрипт подписывается на него и получает кадры.',
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/topic.jpg',
|
||||||
|
description: 'Топик (Topic) в ROS — именованный канал для обмена данными между нодами. Один узел публикует сообщения в топик, другие подписываются и получают их — как радиостанция, на которую настроились несколько приёмников.\n\nЭто «радиоканал» робота: данные текут непрерывно, пока издатель их отправляет.\n\nПримеры топиков на Clover:\n• /main_camera/image/compressed — сжатые кадры с камеры дрона\n• /mavros/state — состояние полётного контроллера (вооружён/не вооружён)\n• /mavros/local_position/pose — текущие координаты дрона\n\nКак работает связка:\n1. Камера (нода image_publisher) постоянно публикует кадры в топик\n2. Ваш скрипт подписывается на этот топик через rospy.Subscriber\n3. Каждый новый кадр автоматически вызывает вашу функцию-обработчик\n\nТопики vs сервисы:\n• Топик — поток данных без ожидания ответа (видео, телеметрия)\n• Сервис — один запрос, один ответ (команда «лети сюда»)\n\nИмя топика всегда начинается с / и уникально в системе ROS.',
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 6,
|
||||||
title: 'Издатель (Publisher)',
|
title: 'Издатель (Publisher)',
|
||||||
body: 'Издатель (Publisher) в ROS — часть программы, которая отправляет данные в топик. Например, если нужно передать команду другому узлу или опубликовать координаты дрона — создается издатель, который публикует сообщения в нужный канал. Подписчики других узлов могут эти данные получать. ',
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/publisher.jpg',
|
||||||
|
description: 'Издатель (Publisher) в ROS — объект в программе, который отправляет (публикует) сообщения в топик. Любая нода может создать издатель и передавать данные всем подписчикам этого топика.\n\nЭто «передатчик» в системе ROS: вы говорите что-то в эфир, а кто настроился — тот слышит.\n\nКак создаётся издатель в Python:\n• pub = rospy.Publisher(\'/my_topic\', String, queue_size=10)\n• pub.publish(сообщение) — отправить данные в топик\n\nГде встречается на практике:\n• Нода камеры Clover публикует изображения — вы только подписываетесь\n• Если нужно передать результат своей обработки другой ноде — создаёте свой издатель\n• Логирование: публикация отладочных сообщений в топик /rosout\n\nПараметры при создании:\n• Имя топика — куда публиковать\n• Тип сообщения (String, Image, PoseStamped и др.) — формат данных\n• queue_size — сколько сообщений хранить в очереди, если подписчик не успевает\n\nВ учебных заданиях Clover чаще используются подписчики (получать данные с камеры), а не издатели. Но понимание издателя важно для полной картины ROS.',
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 7,
|
||||||
title: 'Подписчик (Subscriber)',
|
title: 'Подписчик (Subscriber)',
|
||||||
body: 'Подписчик (Subscriber) в ROS — часть программы, которая получает данные из топика. Например, чтобы дрон "видел" изображение с камеры или считывал свои координаты, создаётся подписчик на соответствующий топик. Как только в топике появляются новые данные, подписчик автоматически их обрабатывает.',
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/subscriber.jpg',
|
||||||
|
description: 'Подписчик (Subscriber) в ROS — объект в программе, который получает данные из топика. Когда в топике появляется новое сообщение, ROS автоматически вызывает вашу callback-функцию с этими данными.\n\nЭто «приёмник» робота: вы настраиваетесь на нужный канал и реагируете на каждое новое сообщение.\n\nКак это работает в коде Clover:\n• rospy.Subscriber(\'/main_camera/image/compressed\', CompressedImage, image_callback)\n• Функция image_callback получает каждый кадр с камеры\n• Внутри callback можно обработать изображение через OpenCV: определить цвет, прочитать QR-код\n\nТипичный сценарий в заданиях:\n1. Подписаться на камеру\n2. В callback получить кадр, декодировать его\n3. Обработать через cv2 — найти цвет платформы или QR-код\n4. Вызвать unregister(), когда данные больше не нужны\n\nВажно:\n• Callback вызывается асинхронно — не блокирует основной поток\n• Не делайте в callback долгих операций — иначе пропустите кадры\n• Всегда отключайте подписчика через unregister(), когда он больше не нужен',
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 8,
|
||||||
title: 'Сервис',
|
title: 'Сервис',
|
||||||
body: 'Сервис в ROS - способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные идут постоянно, сервис работает по принципу "запрос — ответ". Например, команда navigate — это сервис: вы отправляете координаты, а робот отвечает "команда принята". Команда get_telemetry возвращает текущие координаты дрона.',
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/service.jpg',
|
||||||
|
description: 'Сервис (Service) в ROS — способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные текут непрерывно, сервис работает по принципу «запрос — ответ»: один раз спросил, один раз получил результат.\n\nЭто «кнопка с обратной связью»: нажал — робот выполнил и сообщил, получилось или нет.\n\nОсновные сервисы Clover:\n• navigate / navigate_wait — отправить дрон в точку (x, y, z)\n• get_telemetry — получить текущие координаты, высоту, скорость\n• land — посадить дрон\n• arming — вооружить или разоружить моторы\n\nКак вызывается сервис в Python:\n• rospy.wait_for_service(\'navigate\') — дождаться, пока сервис станет доступен\n• navigate = rospy.ServiceProxy(\'navigate\', Navigate)\n• navigate(x=1, y=0, z=1.5, frame_id=\'body\', speed=0.5) — вызов с параметрами\n\nСервис vs топик:\n• Сервис — когда нужен конкретный результат прямо сейчас (координаты, команда полёта)\n• Топик — когда данные приходят постоянно (видеопоток, телеметрия в реальном времени)\n\nВ SkyVue блоки траектории генерируют вызовы navigate_wait — это как раз работа через сервис.',
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206',
|
hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 9,
|
||||||
title: 'Системы координат',
|
title: 'Системы координат',
|
||||||
body: 'Определяют, откуда отсчитывать координаты дрона. body — относительно самого дрона (вперед/назад/влево/вправо). aruco_map — относительно карты с ArUco-метками, где координаты привязаны к неподвижным маркерам. Выбор правильной системы координат позволяет дрону точно выполнять команды: лететь относительно себя для коротких маневров или по глобальной карте для точной навигации.',
|
subtitle: 'Навигация',
|
||||||
|
image: '/images/terms/coordinate-system.jpg',
|
||||||
|
description: 'Системы координат (frames) определяют, откуда отсчитываются координаты дрона: относительно самого дрона, карты ArUco или глобальной системы. Без правильного frame_id команда «лети на x=1, y=0» может отправить дрон не туда.\n\nЭто «точка отсчёта» на карте: одни и те же числа x, y, z означают разное в зависимости от выбранной системы.\n\nОсновные системы на Clover:\n• body — координаты относительно дрона: x — вперёд/назад, y — влево/вправо, z — вверх/вниз. Удобно для коротких манёвров («пролети 2 метра вперёд»)\n• aruco_map — координаты относительно карты ArUco-меток на полу. Маркеры неподвижны, поэтому дрон знает своё абсолютное положение в комнате\n• map — глобальная карта (используется реже в учебных заданиях)\n\nКогда что использовать:\n• body — относительные перемещения от текущей позиции\n• aruco_map — полёт к конкретной точке на карте, навигация между платформами\n• При смене системы координаты пересчитываются автоматически через TF (Transform)\n\nВ SkyVue система координат задаётся в настройках траектории и подставляется в параметр frame_id при вызове navigate_wait.',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
||||||
hrefToDoc: 'https://clover.coex.tech/ru/frames.html',
|
hrefToDoc: 'https://clover.coex.tech/ru/frames.html',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 10,
|
||||||
title: 'ArUco-маркер',
|
title: 'ArUco-маркер',
|
||||||
body: 'ArUco-маркер — квадратный визуальный маркер, который камера дрона может распознавать. Используется для навигации: по положению маркера дрон определяет свои координаты в пространстве.',
|
subtitle: 'Навигация',
|
||||||
image: '/images/terms/ArUco-marker.webp',
|
image: '/images/terms/ArUco-marker.webp',
|
||||||
|
description: 'ArUco-маркер — квадратный чёрно-белый визуальный маркер с уникальным ID внутри. Камера дрона распознаёт его через OpenCV и определяет положение маркера (и своё собственное) в пространстве.\n\nЭто «маяк на полу»: дрон смотрит на маркер и понимает, где он находится относительно карты.\n\nКак работает навигация по ArUco:\n• На полу или стенах размещаются маркеры с известными координатами\n• Камера дрона видит маркеры и вычисляет свою позицию в системе aruco_map\n• Дрон может лететь к точке с точностью до сантиметров — без GPS\n\nГде применяется:\n• Учебные полигоны и соревнования (RoboCup, олимпиады по робототехнике)\n• Полёты в помещении, где GPS не работает\n• Точная посадка на платформу или маркированную точку\n\nНа Clover:\n• Пакет aruco_detect распознаёт маркеры и публикует их позиции\n• Система координат aruco_map привязана к расположению маркеров на карте\n• В симуляторе Gazebo маркеры уже расставлены на сцене\n\nКаждый маркер имеет числовой ID (0, 1, 2...) — по нему система различает маркеры на одной карте.',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
||||||
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html',
|
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 11,
|
||||||
title: 'Симулятор (Gazebo)',
|
title: 'Симулятор (Gazebo)',
|
||||||
body: 'Симулятор (Gazebo) — виртуальная среда, где дрон ведет себя как в реальности. Позволяет безопасно тестировать полетные задания перед запуском на реальном дроне.',
|
subtitle: 'Симуляция',
|
||||||
|
image: '/images/terms/gazebo.jpg',
|
||||||
|
description: 'Gazebo — физический симулятор роботов и дронов. В виртуальной среде дрон ведёт себя как в реальности: летит, реагирует на команды, камера передаёт изображение, ArUco-маркеры видны на полу.\n\nЭто «тренировочный полигон» без риска разбить дрон: ошибся в коде — перезапустил симуляцию, а не чинил пропеллер.\n\nЧто симулирует Gazebo на Clover:\n• Физику полёта: подъёмная сила, инерция, гравитация\n• Камеру дрона с тем же топиком /main_camera/image/compressed\n• ArUco-маркеры на виртуальной карте\n• Все ROS-сервисы: navigate, get_telemetry, land — работают так же, как на реальном дроне\n\nЗачем использовать симулятор:\n• Безопасное тестирование кода перед полётом на реальном Clover\n• Не нужен физический дрон — достаточно компьютера\n• Тот же Python-код запускается и в Gazebo, и на реальном роботе\n• Можно повторять задание сколько угодно раз\n\nКак запустить: roslaunch clover sim_clover.launch — поднимается Gazebo со сценой и дроном. Код из SkyVue можно сразу тестировать в симуляторе через rosrun.',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564',
|
hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564',
|
||||||
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html',
|
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 12,
|
||||||
title: 'unregister()',
|
title: 'unregister()',
|
||||||
body: 'unregister() — метод, который отключает подписчика от топика. Используется, чтобы перестать получать данные, когда они больше не нужны (например, после сканирования QR-кода).',
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/subscriber.jpg',
|
||||||
|
description: 'unregister() — метод объекта подписчика (Subscriber) в ROS, который отключает его от топика. После вызова программа перестаёт получать новые сообщения из этого канала.\n\nЭто «выключить приёмник»: данные больше не нужны — отписываемся, чтобы не тратить ресурсы и не вызывать callback зря.\n\nКогда использовать:\n• После успешного считывания QR-кода — камера больше не нужна для этой задачи\n• Когда определили цвет платформы и пора лететь дальше\n• Перед длительной операцией (navigate_wait), чтобы callback не мешал основному потоку\n• В конце программы — корректное завершение работы с ROS\n\nКак выглядит в коде:\n• sub = rospy.Subscriber(\'/main_camera/image/compressed\', CompressedImage, callback)\n• ... обработка кадров ...\n• sub.unregister() — отключение\n\nЧто будет, если не вызывать unregister():\n• Callback продолжит вызываться на каждый кадр — лишняя нагрузка на CPU\n• Возможны конфликты: callback срабатывает во время navigate_wait\n• При повторном создании подписчика на тот же топик — дублирование обработчиков\n\nПравило: создали подписчик — отключите его, когда задача выполнена.',
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058',
|
hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058',
|
||||||
hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html',
|
hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function toSubtitle(text) {
|
export default termsItems
|
||||||
if (!text) return ''
|
|
||||||
return text.length > 72 ? `${text.slice(0, 72)}…` : text
|
|
||||||
}
|
|
||||||
|
|
||||||
export default rawTerms.map((item, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
title: item.title,
|
|
||||||
subtitle: toSubtitle(item.body),
|
|
||||||
image: item.image ?? '',
|
|
||||||
description: item.body,
|
|
||||||
hrefToDoc: item.hrefToDoc ?? null,
|
|
||||||
hrefToCourse: item.hrefToCourse ?? null,
|
|
||||||
}))
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow, shell } from 'electron';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
|
|
||||||
@@ -7,6 +7,38 @@ if (started) {
|
|||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAppNavigation(url) {
|
||||||
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL && url.startsWith(MAIN_WINDOW_VITE_DEV_SERVER_URL)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.startsWith('file://');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternalLink(url) {
|
||||||
|
shell.openExternal(url).catch((error) => {
|
||||||
|
console.error('Failed to open external link:', url, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureExternalLinks(mainWindow) {
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
openExternalLink(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (isAppNavigation(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
event.preventDefault();
|
||||||
|
openExternalLink(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createWindow = () => {
|
const createWindow = () => {
|
||||||
const { screen } = require('electron');
|
const { screen } = require('electron');
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
@@ -22,6 +54,7 @@ const createWindow = () => {
|
|||||||
});
|
});
|
||||||
mainWindow.setMenuBarVisibility(false);
|
mainWindow.setMenuBarVisibility(false);
|
||||||
mainWindow.maximize();
|
mainWindow.maximize();
|
||||||
|
configureExternalLinks(mainWindow);
|
||||||
|
|
||||||
// and load the index.html of the app.
|
// and load the index.html of the app.
|
||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function publicPath(relativePath) {
|
||||||
|
if (!relativePath) {
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = relativePath.replace(/^\.\//, '').replace(/^\//, '')
|
||||||
|
return `${import.meta.env.BASE_URL}${path}`
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, h, inject, provide } from "vue";
|
import { ref, h, inject, provide } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import izitoast from "@/shared/izitoast";
|
import izitoast from "@/shared/izitoast";
|
||||||
import { useEventListener } from "@vueuse/core";
|
import { useEventListener } from "@vueuse/core";
|
||||||
@@ -16,32 +16,18 @@ import Trash from "@/components/UI/svg/Trash.vue";
|
|||||||
import Back from "@/components/UI/svg/Back.vue";
|
import Back from "@/components/UI/svg/Back.vue";
|
||||||
import { NTooltip } from 'naive-ui'
|
import { NTooltip } from 'naive-ui'
|
||||||
import { useFlightCode } from '@/composables/useFlightCode'
|
import { useFlightCode } from '@/composables/useFlightCode'
|
||||||
|
import { usePlannerState } from '@/composables/usePlannerState'
|
||||||
|
import { publicPath } from '@/shared/publicPath'
|
||||||
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { finallyText } = useFlightCode()
|
const { finallyText } = useFlightCode()
|
||||||
|
const { numbers, clickCount, listOfTrips } = usePlannerState()
|
||||||
|
|
||||||
const numbers = ref([]);
|
|
||||||
const clickCount = ref(1);
|
|
||||||
const listOfTrips = ref([]);
|
|
||||||
const isModalVisible = ref(false);
|
const isModalVisible = ref(false);
|
||||||
const dropMenu = ref(null);
|
const dropMenu = ref(null);
|
||||||
provide('dropMenu', dropMenu);
|
provide('dropMenu', dropMenu);
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
numbers.value.push({
|
|
||||||
variable: [],
|
|
||||||
x: i % 10,
|
|
||||||
y: 9 - Math.floor(i / 10),
|
|
||||||
index: i,
|
|
||||||
isPlatform: false,
|
|
||||||
isQR: false,
|
|
||||||
BgImg: 'images/ArUco-marker.jpg'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setNumber(item) {
|
function setNumber(item) {
|
||||||
let valueOfMarker = item.variable[0];
|
let valueOfMarker = item.variable[0];
|
||||||
if (valueOfMarker === "") {
|
if (valueOfMarker === "") {
|
||||||
@@ -271,7 +257,7 @@ function deletePointMap(item) {
|
|||||||
|
|
||||||
function createIcon(src) {
|
function createIcon(src) {
|
||||||
return h('img', {
|
return h('img', {
|
||||||
src: src,
|
src: publicPath(src),
|
||||||
style: {
|
style: {
|
||||||
width: '20px',
|
width: '20px',
|
||||||
height: '20px',
|
height: '20px',
|
||||||
@@ -343,7 +329,7 @@ function onContextMenu(e, item) {
|
|||||||
<div class="wraper">
|
<div class="wraper">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="square" v-for="(item, index) in numbers" :data-key="index" :key="index" @click="setNumber(item)"
|
<div class="square" v-for="(item, index) in numbers" :data-key="index" :key="index" @click="setNumber(item)"
|
||||||
@contextmenu.prevent="onContextMenu($event, item)" :style="{ backgroundImage: `url(${item.BgImg})` }">
|
@contextmenu.prevent="onContextMenu($event, item)" :style="{ backgroundImage: `url(${publicPath(item.BgImg)})` }">
|
||||||
{{ numbers[index].variable.join(", ") }}
|
{{ numbers[index].variable.join(", ") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,25 +337,33 @@ function onContextMenu(e, item) {
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Save @click="saveResult" />
|
<span class="toolbar-item">
|
||||||
|
<Save @click="saveResult" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
Сохранить
|
Сохранить
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Trash @click="cleanResult" />
|
<span class="toolbar-item">
|
||||||
|
<Trash @click="cleanResult" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
Удалить
|
Удалить
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Back @click="cancel" />
|
<span class="toolbar-item">
|
||||||
|
<Back @click="cancel" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
Отменить
|
Отменить
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-tooltip trigger="hover">
|
<n-tooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Preview @click="openPreview" />
|
<span class="toolbar-item">
|
||||||
|
<Preview @click="openPreview" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
Превью
|
Превью
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
@@ -458,6 +452,7 @@ function onContextMenu(e, item) {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -470,18 +465,33 @@ function onContextMenu(e, item) {
|
|||||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions svg {
|
.toolbar-item {
|
||||||
width: var(--sizeActions);
|
display: inline-flex;
|
||||||
height: var(--sizeActions);
|
align-items: center;
|
||||||
transition: color 0.4s;
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions svg:hover {
|
.toolbar-item svg {
|
||||||
|
width: var(--sizeActions);
|
||||||
|
height: var(--sizeActions);
|
||||||
|
transition:
|
||||||
|
transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
color 0.25s ease;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-item:hover svg {
|
||||||
|
transform: scale(1.22) translateY(-6px);
|
||||||
color: rgba(20, 104, 140, 0.89);
|
color: rgba(20, 104, 140, 0.89);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions svg:active {
|
.toolbar-item:active svg {
|
||||||
transition: color 0s;
|
transition:
|
||||||
|
transform 0.12s ease,
|
||||||
|
color 0s;
|
||||||
|
transform: scale(1.08) translateY(-2px);
|
||||||
color: #23aae3e2;
|
color: #23aae3e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, provide } from 'vue'
|
import { ref, watch, provide, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { resolveTopic } from '@/composables/useTopicResolver'
|
import { resolveTopic } from '@/composables/useTopicResolver'
|
||||||
import MyDropMenu from "@/components/MyDropMenu.vue";
|
import MyDropMenu from "@/components/MyDropMenu.vue";
|
||||||
import CustomButton from '@/components/UI/CustomButton.vue'
|
import CustomButton from '@/components/UI/CustomButton.vue'
|
||||||
|
import { publicPath } from '@/shared/publicPath'
|
||||||
|
|
||||||
|
const APP_LOGO = 'images/drone.png'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -11,6 +14,8 @@ const topic = ref(null)
|
|||||||
const dropMenu = ref(null);
|
const dropMenu = ref(null);
|
||||||
provide('dropMenu', dropMenu);
|
provide('dropMenu', dropMenu);
|
||||||
|
|
||||||
|
const topicImage = computed(() => publicPath(topic.value?.image || APP_LOGO))
|
||||||
|
|
||||||
async function loadTopic() {
|
async function loadTopic() {
|
||||||
topic.value = await resolveTopic(route.params.source, route.params.id)
|
topic.value = await resolveTopic(route.params.source, route.params.id)
|
||||||
|
|
||||||
@@ -39,12 +44,12 @@ function goBack() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="topic-view__image">
|
<div class="topic-view__image">
|
||||||
<img :src="topic.image" :alt="topic.title" />
|
<img :src="topicImage" :alt="topic.title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topic-view__header">
|
<div class="topic-view__header">
|
||||||
<p v-if="topic.subtitle" class="topic-view__subtitle">{{ topic.subtitle }}</p>
|
|
||||||
<h1 class="topic-view__title">{{ topic.title }}</h1>
|
<h1 class="topic-view__title">{{ topic.title }}</h1>
|
||||||
|
<p v-if="topic.subtitle" class="topic-view__subtitle">{{ topic.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="topic-view__description">{{ topic.description }}</p>
|
<p class="topic-view__description">{{ topic.description }}</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue';
|
|||||||
|
|
||||||
// https://vitejs.dev/config
|
// https://vitejs.dev/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||