diff --git a/CHANGELOG.md b/CHANGELOG.md index cf60d26..5dbf425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.3.0 — Radio controls, command presets and CubeSat model + +- добавлена раскрывающаяся схема подключения CC1101 к ESP32-WROOM-32; +- добавлена раскрывающаяся схема подключения E32-433T30D к ESP32-WROOM-32; +- добавлена настройка частоты, мощности и параметров радиоканала прямо из OpenMCC; +- RF-настройки сохраняются локально и формируют будущую команду `$CMD,RADIO,...` для ESP32-шлюза; +- добавлен запрос `RADIO_STATUS` для будущей прошивки радиошлюза; +- добавлен список готовых безопасных команд с пояснениями; +- 3D-модель переработана в более реалистичный 2U CubeSat с фиксированными солнечными панелями; +- удалена условная одиночная штыревая антенна, добавлена раскрытая рулеточная дипольная антенна с двумя противоположными плечами; +- добавлена документация `docs/RADIO_CONTROL_UI.md`; +- версия Windows-приложения повышена до 0.3.0. + ## v0.2.0 — Windows desktop packaging - добавлен desktop-host на Electron; diff --git a/README.md b/README.md index 13085fc..28416eb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenMCC -**OpenMCC (Open Mission Control Center)** — локальный интерфейс учебного центра управления полётами для CubeSat/МКА. Проект работает офлайн, использует Web Serial API для связи с микроконтроллерами и постепенно расширяется до полноценной наземной станции с радиоканалом и автоматическим сопровождением спутника. +**OpenMCC (Open Mission Control Center)** — локальный интерфейс учебного центра управления полётами для CubeSat/МКА. Проект работает офлайн, использует последовательное соединение с микроконтроллерами и постепенно расширяется до полноценной наземной станции с радиоканалом и автоматическим сопровождением спутника. OpenMCC можно запускать двумя способами: @@ -9,23 +9,28 @@ OpenMCC можно запускать двумя способами: ## Что уже работает -- подключение Arduino/STM32/ESP32 через Web Serial; +- подключение Arduino/STM32/ESP32 через Web Serial или desktop-host; - приём и разбор телеметрии; - отправка команд; - журнал событий; - графики в реальном времени на Chart.js; -- 3D-модель CubeSat на Three.js; +- 3D-модель 2U CubeSat на Three.js с рулеточной дипольной антенной; - отдельный интерфейс двухосевой поворотки AZ/EL; - симулятор поворотного устройства без реальной механики; - демонстрационная прошивка Arduino Uno/Nano для телеметрии; - Windows desktop-host на Electron с нативным выбором последовательного порта; -- автоматическая сборка установщика через GitHub Actions. - -## Что пока не реализовано - -- реальный радиошлюз на ESP32-WROOM-32; -- драйвер CC1101; -- драйвер E32-433T30D; +- автоматическая сборка установщика через GitHub Actions; +- интерфейс выбора CC1101 или E32-433T30D; +- раскрывающиеся таблицы подключения радиомодулей к ESP32-WROOM-32; +- настройка RF-параметров в интерфейсе с локальным сохранением и формированием команды для будущего ESP32-шлюза; +- готовые примеры безопасных команд в центре команд. + +## Что пока не реализовано полностью + +- реальная прошивка радиошлюза ESP32-WROOM-32; +- драйвер CC1101 в ESP32; +- драйвер E32-433T30D в ESP32; +- фактическое применение RF-настроек на радиомодуле; - единый пакетный протокол радиоканала с CRC/подтверждениями; - TLE/SGP4; - карта орбиты и радиогоризонт; @@ -38,7 +43,7 @@ OpenMCC можно запускать двумя способами: После публикации релиза пользователь скачивает файл вида: ```text -OpenMCC-Setup-0.2.0.exe +OpenMCC-Setup-0.3.0.exe ``` и запускает обычный мастер установки. Python, Node.js и отдельный браузер пользователю не требуются. @@ -83,6 +88,19 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino Загрузите его в Arduino Uno/Nano, подключите плату к ПК, выберите в OpenMCC `Arduino Uno` и `115200 бод`, затем нажмите **«Подключить устройство»**. +## Радиоканал + +Наземный шлюз планируется на `ESP32-WROOM-32`. + +В панели **«Радиоканал ЦУПа»** можно выбрать: + +- `CC1101` — SPI; +- `E32-433T30D` — UART. + +После выбора OpenMCC показывает рекомендуемое подключение, питание и параметры радиотракта. Подробно: `docs/RADIO_CONTROL_UI.md`. + +До появления прошивки ESP32 кнопка **«Сохранить / передать в шлюз»** всегда сохраняет конфигурацию локально. Если ESP32 подключена, интерфейс дополнительно отправляет команду `$CMD,RADIO,...`; фактическое изменение настроек радиомодуля зависит от будущей прошивки шлюза. + ## Архитектура ```text @@ -110,27 +128,29 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino - `docs/PROJECT_STATUS.md` — что сделано, что проверено и что осталось; - `docs/ARCHITECTURE.md` — архитектура программной и аппаратной части; - `docs/RADIO_LINK.md` — план двустороннего радиоканала CC1101 / E32; +- `docs/RADIO_CONTROL_UI.md` — подключение и управление радиомодулями из OpenMCC; - `docs/DESKTOP_APP.md` — сборка, установка и публикация Windows-приложения; - `CHANGELOG.md` — история заметных изменений. ## Основные модули ```text -index.html интерфейс OpenMCC -css/style.css базовые стили -css/layout.css адаптивная компоновка v0.2 -css/panels.css оформление панелей v0.2 -css/widgets.css элементы управления и подсказки v0.2 -js/parser.js разбор входящих пакетов -js/logger.js журналирование -js/serial.js основное Web Serial-соединение -js/ui.js команды аппарату -js/charts.js графики Chart.js -js/rotator.js отдельное соединение с повороткой -js/satellite.js 3D-модель Three.js -js/help.js подсказки, навигация и встроенная справка -desktop/main.cjs Electron-host и Web Serial для desktop-версии -package.json конфигурация desktop-сборки +index.html интерфейс OpenMCC +css/style.css базовые стили +css/layout.css адаптивная компоновка +css/panels.css оформление панелей +css/widgets.css общие элементы управления +css/radio-controls.css RF-настройки и готовые команды v0.3 +js/parser.js разбор входящих пакетов +js/logger.js журналирование +js/serial.js основное последовательное соединение +js/ui.js команды аппарату +js/charts.js графики Chart.js +js/rotator.js отдельное соединение с повороткой +js/satellite.js 3D-модель 2U CubeSat +js/help.js подсказки, RF UI и готовые команды +desktop/main.cjs Electron-host и Serial для desktop-версии +package.json конфигурация desktop-сборки ``` ## Сборка Windows-установщика для разработчика @@ -142,8 +162,8 @@ npm run dist:win Результат создаётся в `release/`. -GitHub Actions также собирает установщик автоматически. При публикации тега вида `v0.2.0` workflow создаёт GitHub Release и загружает туда Windows installer. +GitHub Actions собирает установщик автоматически. После изменения версии `package.json` и слияния в `main` workflow может создать новый GitHub Release с установщиком соответствующей версии. ## Важный принцип проекта -OpenMCC должен оставаться независимым от конкретного радиомодуля. CC1101 и E32-433T30D будут скрыты за ESP32-WROOM-32, а интерфейс продолжит работать с единым протоколом телеметрии и команд. +OpenMCC должен оставаться независимым от конкретного радиомодуля. CC1101 и E32-433T30D скрываются за ESP32-WROOM-32, а интерфейс телеметрии и команд остаётся единым. diff --git a/css/radio-controls.css b/css/radio-controls.css new file mode 100644 index 0000000..10902cd --- /dev/null +++ b/css/radio-controls.css @@ -0,0 +1,247 @@ +/* OpenMCC v0.3 — radio configuration and command presets */ + +#radioAdvancedControls { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.radioEmptyState { + display: grid; + gap: 5px; + padding: 14px; + border: 1px dashed rgba(71, 103, 137, .75); + border-radius: 10px; + background: rgba(5, 12, 23, .38); +} + +.radioEmptyState strong { color: #d8e9f7; font-size: 12px; } +.radioEmptyState span { color: #7893ac; font-size: 11px; line-height: 1.5; } + +.radioDetails { + overflow: hidden; + border: 1px solid rgba(43, 73, 108, .72); + border-radius: 11px; + background: rgba(5, 12, 23, .45); +} + +.radioDetails summary { + padding: 11px 13px; + cursor: pointer; + color: #cfe8f5; + font-size: 11px; + font-weight: 700; + letter-spacing: .02em; + background: rgba(15, 29, 49, .7); +} + +.radioDetails[open] summary { border-bottom: 1px solid rgba(43, 73, 108, .62); } +.radioDetailsBody { padding: 12px; } + +.radioPinTable { + width: 100%; + border-collapse: collapse; + font-size: 10px; +} + +.radioPinTable th, +.radioPinTable td { + padding: 7px 8px; + border-bottom: 1px solid rgba(39, 66, 98, .52); + text-align: left; + vertical-align: top; +} + +.radioPinTable th { + color: #7fa1bd; + font-size: 9px; + letter-spacing: .07em; + text-transform: uppercase; +} + +.radioPinTable td:first-child, +.radioPinTable td:nth-child(2) { + color: #d9eef8; + font-family: Consolas, monospace; + white-space: nowrap; +} + +.radioPinTable td:last-child { color: #829bb2; } + +.radioSafetyNote, +.radioRegulatoryNote { + margin: 10px 0 0; + color: #8da5bb; + font-size: 10px; + line-height: 1.55; +} + +.radioRegulatoryNote { + padding: 9px 10px; + border-left: 2px solid rgba(255, 193, 7, .68); + background: rgba(255, 193, 7, .035); +} + +.radioConfigCard { + padding: 13px; + border: 1px solid rgba(43, 73, 108, .75); + border-radius: 11px; + background: linear-gradient(180deg, rgba(9, 20, 35, .78), rgba(5, 12, 23, .72)); +} + +.radioConfigHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} + +.radioConfigHeader > div { display: grid; gap: 3px; } +.radioConfigHeader span { color: #6f8ca8; font-size: 9px; letter-spacing: .09em; text-transform: uppercase; } +.radioConfigHeader strong { color: #dcecf8; font-size: 13px; } + +.radioLocalBadge { + padding: 4px 7px; + border: 1px solid rgba(73, 112, 148, .6); + border-radius: 999px; + color: #7898b4 !important; + font-size: 8px !important; + white-space: nowrap; +} + +.radioControlGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.radioControlGrid > label:not(.radioCheck) { + display: grid; + gap: 6px; +} + +.radioControlGrid label > span { + color: #8099b0; + font-size: 9px; + font-weight: 700; + letter-spacing: .07em; + text-transform: uppercase; +} + +.radioControlGrid select, +.radioControlGrid input[type="number"] { + width: 100%; + min-height: 38px; + padding: 0 10px; + color: #e4f1fb; + border: 1px solid rgba(45, 75, 109, .85); + border-radius: 8px; + background: #14223a; +} + +.radioCheck { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 8px 10px; + border: 1px solid rgba(45, 75, 109, .72); + border-radius: 8px; + background: rgba(20, 34, 58, .62); +} + +.radioCheck input { accent-color: #00d9ff; } +.radioCheck span { margin: 0; } + +.radioCommandPreview { + display: grid; + gap: 6px; + margin-top: 12px; + padding: 10px; + border: 1px solid rgba(42, 70, 102, .65); + border-radius: 9px; + background: rgba(3, 9, 17, .72); +} + +.radioCommandPreview span { + color: #6d89a3; + font-size: 9px; + letter-spacing: .08em; + text-transform: uppercase; +} + +.radioCommandPreview code { + overflow-wrap: anywhere; + color: #8feaff; + font: 600 10px/1.5 Consolas, monospace; +} + +.radioActionRow { + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 8px; + margin-top: 10px; +} + +.radioApplyButton, +.radioSecondaryButton { + min-height: 40px; + margin: 0; + border-radius: 8px; + font-size: 11px; +} + +.radioApplyButton { + color: #041019; + border-color: #21dfff; + background: linear-gradient(135deg, #14d9ff, #62e8ff); + font-weight: 750; +} + +.radioSecondaryButton { + color: #b4c9da; + border-color: rgba(56, 86, 119, .85); + background: rgba(17, 31, 52, .72); +} + +.radioApplyStatus { + margin-top: 9px; + color: #7f98ae; + font-size: 10px; + line-height: 1.45; +} + +.radioApplyStatus[data-state="saved"] { color: #8fd6b3; } +.radioApplyStatus[data-state="sent"] { color: #77e5ff; } +.radioApplyStatus[data-state="error"] { color: #ff8d8d; } + +.commandPresetGroup { margin-top: 2px; } + +.commandPresetRow { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(150px, .8fr); + gap: 8px; +} + +.commandPresetRow select { + width: 100%; + min-height: 42px; + padding: 0 10px; + color: #e3f0fb; + border: 1px solid rgba(45, 75, 109, .85); + border-radius: 8px; + background: #14223a; +} + +.commandPresetRow button { margin: 0; } +.commandPresetNote { margin-top: 8px; color: #7892a8; font-size: 10px; line-height: 1.45; } + +@media (max-width: 720px) { + .radioControlGrid, + .radioActionRow, + .commandPresetRow { grid-template-columns: 1fr; } + .radioPinTable { font-size: 9px; } + .radioPinTable th, + .radioPinTable td { padding: 6px; } +} diff --git a/docs/RADIO_CONTROL_UI.md b/docs/RADIO_CONTROL_UI.md new file mode 100644 index 0000000..134949f --- /dev/null +++ b/docs/RADIO_CONTROL_UI.md @@ -0,0 +1,93 @@ +# Радиоканал OpenMCC — интерфейс настройки v0.3 + +## Назначение + +Панель **«Радиоканал ЦУПа»** предназначена для будущего наземного радиошлюза на `ESP32-WROOM-32`. + +В OpenMCC можно выбрать один из двух вариантов радиомодуля: + +- `CC1101` — подключение к ESP32 по SPI; +- `E32-433T30D` — подключение к ESP32 по UART. + +После выбора модуля интерфейс показывает рекомендуемую разводку, доступные параметры и текст команды, которая будет передана прошивке ESP32. + +> В версии v0.3 графический интерфейс уже готов, однако драйверы CC1101/E32 в прошивке ESP32 ещё не реализованы. Поэтому настройки гарантированно сохраняются локально, а реальное применение на радиомодуле появится после разработки прошивки радиошлюза. + +## CC1101 + +Рекомендуемая разводка OpenMCC: + +| ESP32-WROOM-32 | CC1101 | Назначение | +|---|---|---| +| 3V3 | VCC | питание 3,3 В | +| GND | GND | общая земля | +| GPIO18 | SCK | SPI clock | +| GPIO23 | SI / MOSI | данные ESP32 → CC1101 | +| GPIO19 | SO / MISO | данные CC1101 → ESP32 | +| GPIO21 | CSn | выбор SPI-устройства | +| GPIO4 | GDO0 | прерывание / событие пакета | +| GPIO27 | GDO2 | дополнительный цифровой сигнал | + +Интерфейс позволяет подготовить: + +- частоту; +- мощность передатчика; +- скорость в эфире; +- модуляцию; +- полосу приёмного фильтра; +- аппаратный CRC; +- CCA перед передачей. + +TI указывает для CC1101 диапазоны 300–348, 387–464 и 779–928 МГц, программируемую скорость 0,6–600 кбит/с и мощность до +12 dBm. Конкретный модуль и согласующая цепь должны соответствовать используемому диапазону. + +Пример будущей команды ESP32: + +```text +$CMD,RADIO,TYPE=CC1101,FREQ=435.000,POWER=10,RATE=38.4,MOD=GFSK,BW=AUTO,CRC=1,CCA=1 +``` + +## E32-433T30D + +Рекомендуемая разводка OpenMCC: + +| ESP32-WROOM-32 / питание | E32-433T30D | Назначение | +|---|---|---| +| внешний стабилизированный 5 В, не менее 1 А | VCC | питание радиомодуля | +| GND ESP32 + GND источника | GND | общая земля | +| GPIO17 / TX2 | RXD | ESP32 → E32 | +| GPIO16 / RX2 | TXD | E32 → ESP32 | +| GPIO25 | M0 | выбор режима | +| GPIO26 | M1 | выбор режима | +| GPIO27 | AUX | состояние / готовность | + +Ebyte указывает питание 3,3–5,2 В, рекомендуемый логический уровень 3,3 В и ток передачи до примерно 670 мА при 30 dBm. Поэтому 1-ваттный модуль не следует питать от слабого выхода 3V3 отладочной платы. + +Интерфейс позволяет подготовить: + +- частоту 410–441 МГц; +- мощность 21/24/27/30 dBm; +- воздушную скорость 2,4/4,8/9,6/19,2 кбит/с; +- скорость UART; +- прозрачный или фиксированный режим передачи; +- FEC. + +Пример будущей команды ESP32: + +```text +$CMD,RADIO,TYPE=E32,FREQ=433,POWER=30,AIR=2.4,UART=9600,MODE=TRANSPARENT,FEC=1 +``` + +## Как пользоваться + +1. Подключить `ESP32-WROOM-32` к основному USB-порту OpenMCC. +2. В панели соединения выбрать профиль `ESP32` и нужную скорость USB-порта. +3. В панели радиоканала выбрать `CC1101` или `E32-433T30D`. +4. Раскрыть блок подключения и сверить проводку. +5. Задать RF-параметры. +6. Нажать **«Сохранить / передать в шлюз»**. + +Без подключённой ESP32 параметры сохраняются в локальном хранилище OpenMCC. При подключённом шлюзе формируется команда `$CMD,RADIO,...`. + +## Радиорегулирование + +OpenMCC технически позволяет вводить частоту и мощность, поддерживаемые радиомодулем. Пользователь обязан самостоятельно выбирать только разрешённые частоты, уровни мощности и режимы работы в соответствии с местным законодательством, лицензией и назначением станции. diff --git a/js/help.js b/js/help.js index 463f99c..8281f76 100644 --- a/js/help.js +++ b/js/help.js @@ -1,49 +1,54 @@ "use strict"; /* ============================================================ - OpenMCC v0.2 - Interface help, tooltips, quick navigation and UI-only - radio gateway planning controls. + OpenMCC v0.3 + Interface help, tooltips, quick navigation, radio setup UI + and command presets. ============================================================ */ (() => { - const HELP_VERSION = "0.2.0"; - const FIRST_RUN_KEY = "openmcc-help-v02-seen"; + const HELP_VERSION = "0.3.0"; + const FIRST_RUN_KEY = "openmcc-help-v03-seen"; const RADIO_KEY = "openmcc-radio-module"; + const RADIO_SETTINGS_PREFIX = "openmcc-radio-settings-"; const tooltipDefinitions = [ ["#connectButton", "Открывает системный выбор последовательного USB-порта. Для тестовой Arduino Uno используйте 115200 бод."], - ["#deviceProfile", "Профиль помогает OpenMCC корректно подписать устройство. Если не уверены — оставьте автоматическое определение."], - ["#serialBaudRate", "Скорость обмена по USB. Демонстратор Arduino и текущие прошивки OpenMCC используют 115200 бод."], + ["#deviceProfile", "Профиль помогает OpenMCC корректно подписать устройство. Для радиошлюза выбирайте ESP32."], + ["#serialBaudRate", "Скорость обмена по USB. Демонстратор Arduino и будущий ESP32-шлюз рассчитаны на 115200 бод."], ["#packetCounter", "Число принятых телеметрических пакетов за текущий сеанс."], - ["#crcCounter", "Счётчик ошибок/контрольных событий. В текущем текстовом протоколе полноценный CRC радиопакета ещё не внедрён."], + ["#crcCounter", "Счётчик ошибок/контрольных событий. Радиопакетный CRC будет реализован в прошивке ESP32-шлюза."], ["#telemetryPanel", "Здесь отображаются последние принятые значения TEMP, VOLT, CURR, RSSI и SNR."], - ["#commandPanel", "Команды уходят в подключённое основное устройство. Сейчас это USB-плата; позже тем же интерфейсом будет управляться ESP32-радиошлюз."], + ["#commandPanel", "Команды уходят в подключённое основное устройство. Позже это будет ESP32-радиошлюз, который передаст команду аппарату по радио."], ["[data-command='PING']", "Проверка канала команд. Демонстратор Arduino отвечает $ACK,PONG."], ["[data-command='INFO']", "Запрашивает сведения об устройстве и версии протокола."], ["[data-command='START']", "Разрешает периодическую передачу телеметрии на демонстраторе."], ["[data-command='STOP']", "Останавливает периодическую передачу телеметрии на демонстраторе."], ["#telemetryRate", "Выберите период между телеметрическими пакетами."], ["#applyTelemetryRate", "Отправляет устройству команду изменения периода телеметрии."], - ["#customCommand", "Произвольная команда протокола OpenMCC. Используйте только команды, которые поддерживает подключённая прошивка."], + ["#customCommand", "Произвольная команда протокола OpenMCC. Используйте только команды, поддерживаемые подключённой прошивкой."], + ["#commandPresetSelect", "Готовые безопасные примеры команд. Часть команд относится к будущей прошивке МКА и может вернуть UNKNOWN_COMMAND на демонстраторе."], + ["#commandPresetSend", "Отправляет выбранную команду через тот же канал, что и обычный центр команд."], ["#rotatorConnectButton", "Открывает второй независимый последовательный порт для Arduino Uno поворотного устройства."], ["#rotatorSimulationMode", "Позволяет проверять весь интерфейс AZ/EL без собранного SatNOGS Rotator v3."], ["#rotatorTargetAz", "Целевой азимут антенны. В текущей логике допустим диапазон 0…360°."], ["#rotatorTargetEl", "Целевой угол места антенны. В текущей логике допустим диапазон 0…90°."], ["#rotatorMoveButton", "Передаёт команду $ROT,SET,AZ=…,EL=… в контроллер поворотки или симулятор."], - ["#rotatorStopButton", "Останавливает движение. Для реальной механики всё равно нужна отдельная аппаратная аварийная остановка."], + ["#rotatorStopButton", "Останавливает движение. Для реальной механики нужна также отдельная аппаратная аварийная остановка."], ["#rotatorHomeButton", "Запускает поиск нулевого положения по концевым выключателям. Реальную механику пока не проверяли."], ["#rotatorParkButton", "Команда перевода антенны в заранее заданное парковочное положение."], ["#rotatorAutoMode", "Автоматическое сопровождение будет включено после реализации TLE/SGP4 и расчёта AZ/EL."], - ["#radioPanel", "Планируемый наземный радиошлюз. Выбранная базовая плата — ESP32-WROOM-32; радиомодуль зависит от закупки."], - ["#radioModuleType", "Выберите предполагаемый вариант радиомодуля. Это пока только настройка интерфейса и документации, не реальный драйвер."], + ["#radioPanel", "Настройка наземного радиошлюза ESP32-WROOM-32. Здесь можно выбрать CC1101 или E32-433T30D, посмотреть подключение и подготовить RF-конфигурацию."], + ["#radioModuleType", "Выберите установленный радиомодуль. Параметры и схема подключения ниже перестроятся автоматически."], + ["#radioApplyButton", "Сохраняет RF-настройки локально. Если ESP32 подключена, OpenMCC также отправит конфигурацию в шлюз; применение зависит от прошивки ESP32."], + ["#radioReadButton", "Запрашивает текущую RF-конфигурацию у ESP32-шлюза. Команда заработает после реализации соответствующей прошивки."], ["#pauseChartsButton", "Замораживает добавление новых точек на графики без остановки самой телеметрии."], ["#clearChartsButton", "Очищает накопленные точки графиков текущего сеанса."], - ["#satelliteViewport", "Процедурная 3D-модель CubeSat. При появлении ROLL/PITCH/YAW модель сможет отражать ориентацию аппарата."], + ["#satelliteViewport", "Условная 2U-модель CubeSat с фиксированными солнечными панелями и раскрытой рулеточной дипольной антенной."], ["#resetSatelliteView", "Возвращает камеру 3D-вида в исходное положение."], ["#toggleSatelliteRotation", "Включает или выключает демонстрационное автовращение модели."], ["#mapPanel", "Карта пока не реализована. Следующий крупный этап — TLE/SGP4, трасса орбиты, положение ЦУПа и автоматическое сопровождение."], - ["#eventLog", "Журнал сохраняет ключевые события соединения, команд и ошибок. Он нужен для диагностики работы ЦУПа."], + ["#eventLog", "Журнал сохраняет ключевые события соединения, команд и ошибок."], ["#helpButton", "Открывает краткую встроенную инструкцию. Горячая клавиша: F1."], ]; @@ -52,34 +57,59 @@ label: "CC1101 / E32", interfaceLabel: "SPI / UART", interfaceValue: "SPI или UART", - powerValue: "Уточнить после закупки", - hint: "Модуль ещё не выбран. Архитектура OpenMCC специально оставлена независимой от конкретного радиомодуля.", + powerValue: "Выберите модуль ниже", + hint: "Модуль ещё не выбран. После выбора появятся схема подключения, диапазоны частоты, мощности и другие параметры.", }, cc1101: { label: "CC1101", interfaceLabel: "SPI", interfaceValue: "SPI + GDO0/GDO2", powerValue: "3,3 В; логика 3,3 В", - hint: "CC1101 подключается к ESP32-WROOM-32 по SPI. Драйвер радиоканала и пакетный CRC ещё предстоит реализовать.", + hint: "CC1101 — гибко настраиваемый Sub-1 GHz трансивер. Для 433/435 МГц используйте модуль и антенну, рассчитанные на этот диапазон.", }, e32: { label: "E32-433T30D", interfaceLabel: "UART", interfaceValue: "UART + M0/M1/AUX", - powerValue: "Отдельное питание модуля; общая GND", - hint: "E32-433T30D подключается к ESP32-WROOM-32 по UART. Мощную версию не следует питать от слабого вывода 3,3 В отладочной платы.", + powerValue: "3,3–5,2 В; при 30 dBm до ~670 мА", + hint: "E32-433T30D — UART LoRa-модуль до 30 dBm. Для 1 Вт предусмотрите отдельное питание с общей землёй и 3,3-вольтовыми логическими уровнями.", }, }; + const commandPresets = [ + { label: "PING — проверка связи", command: "PING", params: null, note: "Безопасная проверка командного канала. Работает с демонстратором Arduino." }, + { label: "INFO — сведения об устройстве", command: "INFO", params: null, note: "Запрашивает тип устройства и версию протокола. Работает с демонстратором." }, + { label: "START — запустить телеметрию", command: "START", params: null, note: "Включает периодическую телеметрию на демонстраторе." }, + { label: "STOP — остановить телеметрию", command: "STOP", params: null, note: "Останавливает периодическую телеметрию на демонстраторе." }, + { label: "LED ON — включить индикатор", command: "LED", params: { VALUE: 1 }, note: "Включает встроенный LED демонстрационной Arduino." }, + { label: "LED OFF — выключить индикатор", command: "LED", params: { VALUE: 0 }, note: "Выключает встроенный LED демонстрационной Arduino." }, + { label: "RATE 1000 — телеметрия 1 раз/с", command: "RATE", params: { VALUE: 1000 }, note: "Устанавливает период телеметрии 1000 мс." }, + { label: "STATUS — запрос состояния МКА", command: "STATUS", params: null, note: "Зарезервированный пример для будущей бортовой прошивки. Демонстратор может ответить UNKNOWN_COMMAND." }, + { label: "POWER — запрос состояния питания", command: "POWER", params: null, note: "Зарезервированный пример для будущей бортовой прошивки." }, + { label: "RADIO_STATUS — состояние радиошлюза", command: "RADIO_STATUS", params: null, note: "Будет использоваться ESP32-шлюзом после реализации его прошивки." }, + ]; + let tooltipElement = null; let activeTooltipTarget = null; + function loadEnhancedStyles() { + if (document.querySelector("link[data-openmcc-v03]")) return; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "css/radio-controls.css"; + link.dataset.openmccV03 = "1"; + document.head.appendChild(link); + } + + function updateBuildBadge() { + const badge = document.querySelector(".buildBadge"); + if (badge) badge.textContent = "v0.3 · LAB"; + } + function addTooltips() { tooltipDefinitions.forEach(([selector, text]) => { document.querySelectorAll(selector).forEach((element) => { - if (!element.dataset.tip) { - element.dataset.tip = text; - } + if (!element.dataset.tip) element.dataset.tip = text; if (!element.getAttribute("aria-label") && ["BUTTON", "INPUT", "SELECT"].includes(element.tagName)) { element.setAttribute("aria-label", text); } @@ -91,31 +121,20 @@ if (!tooltipElement || !target) return; const text = target.dataset.tip; if (!text) return; - tooltipElement.textContent = text; tooltipElement.setAttribute("aria-hidden", "false"); tooltipElement.classList.add("visible"); - const padding = 12; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const tooltipRect = tooltipElement.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); - let left = pointerEvent?.clientX ?? (targetRect.left + targetRect.width / 2); let top = pointerEvent?.clientY ?? targetRect.bottom; - left += 14; top += 14; - - if (left + tooltipRect.width + padding > viewportWidth) { - left = Math.max(padding, viewportWidth - tooltipRect.width - padding); - } - - if (top + tooltipRect.height + padding > viewportHeight) { - top = Math.max(padding, (pointerEvent?.clientY ?? targetRect.top) - tooltipRect.height - 14); - } - + if (left + tooltipRect.width + padding > viewportWidth) left = Math.max(padding, viewportWidth - tooltipRect.width - padding); + if (top + tooltipRect.height + padding > viewportHeight) top = Math.max(padding, (pointerEvent?.clientY ?? targetRect.top) - tooltipRect.height - 14); tooltipElement.style.left = `${left}px`; tooltipElement.style.top = `${top}px`; } @@ -130,32 +149,27 @@ function initializeTooltipSystem() { tooltipElement = document.getElementById("openmccTooltip"); if (!tooltipElement) return; - document.addEventListener("pointerover", (event) => { const target = event.target.closest?.("[data-tip]"); if (!target) return; activeTooltipTarget = target; placeTooltip(target, event); }); - document.addEventListener("pointermove", (event) => { if (activeTooltipTarget) placeTooltip(activeTooltipTarget, event); }); - document.addEventListener("pointerout", (event) => { if (!activeTooltipTarget) return; const nextTarget = event.relatedTarget; if (nextTarget && activeTooltipTarget.contains(nextTarget)) return; hideTooltip(); }); - document.addEventListener("focusin", (event) => { const target = event.target.closest?.("[data-tip]"); if (!target) return; activeTooltipTarget = target; placeTooltip(target); }); - document.addEventListener("focusout", hideTooltip); window.addEventListener("scroll", hideTooltip, { passive: true }); window.addEventListener("resize", hideTooltip); @@ -164,8 +178,7 @@ function initializeNavigation() { document.querySelectorAll("[data-scroll-target]").forEach((button) => { button.addEventListener("click", () => { - const target = document.getElementById(button.dataset.scrollTarget); - target?.scrollIntoView({ behavior: "smooth", block: "start" }); + document.getElementById(button.dataset.scrollTarget)?.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); } @@ -175,30 +188,21 @@ if (!dialog) return; if (typeof dialog.showModal === "function") { if (!dialog.open) dialog.showModal(); - } else { - dialog.setAttribute("open", ""); - } + } else dialog.setAttribute("open", ""); } function closeHelp() { const dialog = document.getElementById("helpDialog"); if (!dialog) return; - if (typeof dialog.close === "function" && dialog.open) { - dialog.close(); - } else { - dialog.removeAttribute("open"); - } + if (typeof dialog.close === "function" && dialog.open) dialog.close(); + else dialog.removeAttribute("open"); } function initializeHelpDialog() { document.querySelectorAll("[data-help-open]").forEach((button) => button.addEventListener("click", openHelp)); document.querySelectorAll("[data-help-close]").forEach((button) => button.addEventListener("click", closeHelp)); - const dialog = document.getElementById("helpDialog"); - dialog?.addEventListener("click", (event) => { - if (event.target === dialog) closeHelp(); - }); - + dialog?.addEventListener("click", (event) => { if (event.target === dialog) closeHelp(); }); document.addEventListener("keydown", (event) => { if (event.key === "F1") { event.preventDefault(); @@ -214,66 +218,342 @@ const interfaceValue = document.getElementById("radioInterfaceValue"); const powerValue = document.getElementById("radioPowerValue"); const hint = document.getElementById("radioConfigHint"); - if (moduleLabel) moduleLabel.textContent = profile.label; if (interfaceLabel) interfaceLabel.textContent = profile.interfaceLabel; if (interfaceValue) interfaceValue.textContent = profile.interfaceValue; if (powerValue) powerValue.textContent = profile.powerValue; if (hint) hint.textContent = profile.hint; + renderRadioControls(profileId); + } + + function getRadioSettingsKey(profileId) { + return `${RADIO_SETTINGS_PREFIX}${profileId}`; + } + + function loadRadioSettings(profileId, defaults) { + try { + const parsed = JSON.parse(localStorage.getItem(getRadioSettingsKey(profileId)) || "null"); + return { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) }; + } catch { + return { ...defaults }; + } + } + + function saveRadioSettings(profileId, settings) { + localStorage.setItem(getRadioSettingsKey(profileId), JSON.stringify(settings)); + } + + function radioPinTable(profileId) { + const rows = profileId === "cc1101" ? [ + ["3V3", "VCC", "Питание 3,3 В"], + ["GND", "GND", "Общая земля"], + ["GPIO18", "SCK", "SPI clock"], + ["GPIO23", "SI / MOSI", "ESP32 → CC1101"], + ["GPIO19", "SO / MISO", "CC1101 → ESP32"], + ["GPIO21", "CSn", "Chip Select"], + ["GPIO4", "GDO0", "Прерывание / пакет"], + ["GPIO27", "GDO2", "Доп. сигнал состояния"], + ] : [ + ["Внешние 5 В ≥ 1 А", "VCC", "Питание; не от слабого 3V3 ESP32"], + ["GND ESP32 + БП", "GND", "Общая земля обязательна"], + ["GPIO17 / TX2", "RXD", "ESP32 → E32"], + ["GPIO16 / RX2", "TXD", "E32 → ESP32"], + ["GPIO25", "M0", "Выбор режима"], + ["GPIO26", "M1", "Выбор режима"], + ["GPIO27", "AUX", "Готовность / занятость"], + ]; + return rows.map(([esp, radio, purpose]) => `${esp}${radio}${purpose}`).join(""); + } + + function cc1101Controls(settings) { + return ` +
+ + + + + + + +
`; + } + + function e32Controls(settings) { + return ` +
+ + + + + + +
`; + } + + function renderRadioControls(profileId) { + const panel = document.getElementById("radioPanel"); + if (!panel) return; + let container = document.getElementById("radioAdvancedControls"); + if (!container) { + container = document.createElement("div"); + container.id = "radioAdvancedControls"; + const hint = document.getElementById("radioConfigHint"); + panel.insertBefore(container, hint || null); + } + if (profileId === "unknown") { + container.innerHTML = `
Выберите CC1101 или E32-433T30DПосле выбора здесь появятся подключение и параметры радиотракта.
`; + return; + } + + const defaults = profileId === "cc1101" + ? { frequency: 435.0, power: 10, rate: 38.4, modulation: "GFSK", bandwidth: "AUTO", crc: true, cca: true } + : { frequency: 433, power: 30, airRate: 2.4, uartRate: 9600, mode: "TRANSPARENT", fec: true }; + const settings = loadRadioSettings(profileId, defaults); + + container.innerHTML = ` +
+ Подключение ${radioProfiles[profileId].label} к ESP32-WROOM-32 +
+ ${radioPinTable(profileId)}
ESP32 / питание${radioProfiles[profileId].label}Назначение
+

${profileId === "cc1101" + ? "CC1101 работает с логикой 3,3 В. Не подавайте на сигнальные выводы 5 В. Выбранная разводка GPIO является конфигурацией OpenMCC и может быть изменена в прошивке ESP32." + : "E32-433T30D при 30 dBm потребляет большой импульсный ток. Используйте отдельный стабилизированный источник питания с общей GND; сигнальные уровни рекомендуется держать 3,3 В."}

+
+
+
+
Параметры радиотракта${radioProfiles[profileId].label}
СОХРАНЯЮТСЯ ЛОКАЛЬНО
+ ${profileId === "cc1101" ? cc1101Controls(settings) : e32Controls(settings)} +
Команда для ESP32
+
+ + +
+
Настройки ещё не передавались.
+
Частоту и мощность выбирайте только в пределах разрешённых для вашей станции, диапазона и лицензии. Интерфейс OpenMCC не отменяет требования радиорегулирования.
+
`; + + bindRadioControlEvents(profileId); + addTooltips(); + } + + function collectRadioSettings(profileId) { + const frequency = Number(document.getElementById("radioFrequency")?.value); + const power = Number(document.getElementById("radioTxPower")?.value); + if (profileId === "cc1101") { + return { + frequency, + power, + rate: Number(document.getElementById("radioDataRate")?.value), + modulation: document.getElementById("radioModulation")?.value || "GFSK", + bandwidth: document.getElementById("radioBandwidth")?.value || "AUTO", + crc: Boolean(document.getElementById("radioCrc")?.checked), + cca: Boolean(document.getElementById("radioCca")?.checked), + }; + } + return { + frequency, + power, + airRate: Number(document.getElementById("radioAirRate")?.value), + uartRate: Number(document.getElementById("radioUartRate")?.value), + mode: document.getElementById("radioE32Mode")?.value || "TRANSPARENT", + fec: Boolean(document.getElementById("radioFec")?.checked), + }; + } + + function validateRadioSettings(profileId, settings) { + const [minimum, maximum] = profileId === "cc1101" ? [387, 464] : [410, 441]; + if (!Number.isFinite(settings.frequency) || settings.frequency < minimum || settings.frequency > maximum) { + throw new Error(`Частота должна быть в диапазоне ${minimum}…${maximum} МГц`); + } + } + + function buildRadioParameters(profileId, settings) { + if (profileId === "cc1101") { + return { + TYPE: "CC1101", + FREQ: settings.frequency.toFixed(3), + POWER: settings.power, + RATE: settings.rate, + MOD: settings.modulation.replace("-", ""), + BW: settings.bandwidth, + CRC: settings.crc ? 1 : 0, + CCA: settings.cca ? 1 : 0, + }; + } + return { + TYPE: "E32", + FREQ: settings.frequency.toFixed(0), + POWER: settings.power, + AIR: settings.airRate, + UART: settings.uartRate, + MODE: settings.mode, + FEC: settings.fec ? 1 : 0, + }; + } + + function previewRadioCommand(profileId) { + const preview = document.getElementById("radioCommandPreview"); + if (!preview) return; + try { + const settings = collectRadioSettings(profileId); + validateRadioSettings(profileId, settings); + const params = buildRadioParameters(profileId, settings); + preview.textContent = `$CMD,RADIO,${Object.entries(params).map(([key, value]) => `${key}=${value}`).join(",")}`; + } catch (error) { + preview.textContent = error.message; + } + } + + function setRadioStatus(text, type = "neutral") { + const element = document.getElementById("radioApplyStatus"); + if (!element) return; + element.textContent = text; + element.dataset.state = type; + } + + async function applyRadioSettings(profileId) { + try { + const settings = collectRadioSettings(profileId); + validateRadioSettings(profileId, settings); + saveRadioSettings(profileId, settings); + previewRadioCommand(profileId); + const serialState = window.OpenMCCSerial?.getState?.(); + if (!serialState?.connected) { + setRadioStatus("Сохранено на этом компьютере. Подключите ESP32-шлюз, чтобы передать настройки в устройство.", "saved"); + window.OpenMCCLogger?.write?.("RF-настройки сохранены локально", "success", "RADIO", { profileId, settings }); + return; + } + const parameters = buildRadioParameters(profileId, settings); + await window.OpenMCCUI.transmitCommand("RADIO", parameters); + setRadioStatus("Конфигурация отправлена в ESP32. Фактическое применение требует прошивки радиошлюза v0.3+.", "sent"); + } catch (error) { + setRadioStatus(`Ошибка: ${error.message}`, "error"); + window.OpenMCCLogger?.write?.(`RF-конфигурация не применена: ${error.message}`, "error", "RADIO"); + } + } + + async function requestRadioSettings() { + const serialState = window.OpenMCCSerial?.getState?.(); + if (!serialState?.connected) { + setRadioStatus("Сначала подключите ESP32-шлюз через основной порт.", "error"); + return; + } + try { + await window.OpenMCCUI.transmitCommand("RADIO_STATUS"); + setRadioStatus("Запрос RADIO_STATUS отправлен. Ответ появится после реализации прошивки ESP32-шлюза.", "sent"); + } catch (error) { + setRadioStatus(`Запрос не отправлен: ${error.message}`, "error"); + } + } + + function bindRadioControlEvents(profileId) { + document.querySelectorAll("#radioAdvancedControls input, #radioAdvancedControls select").forEach((control) => { + control.addEventListener("input", () => previewRadioCommand(profileId)); + control.addEventListener("change", () => previewRadioCommand(profileId)); + }); + document.getElementById("radioApplyButton")?.addEventListener("click", () => applyRadioSettings(profileId)); + document.getElementById("radioReadButton")?.addEventListener("click", requestRadioSettings); + previewRadioCommand(profileId); } function initializeRadioPlanner() { const select = document.getElementById("radioModuleType"); if (!select) return; - const storedValue = localStorage.getItem(RADIO_KEY); - if (storedValue && radioProfiles[storedValue]) { - select.value = storedValue; - } - + if (storedValue && radioProfiles[storedValue]) select.value = storedValue; updateRadioProfile(select.value); - select.addEventListener("change", () => { localStorage.setItem(RADIO_KEY, select.value); updateRadioProfile(select.value); }); } + function initializeCommandPresets() { + const panel = document.getElementById("commandPanel"); + const lastCommand = document.querySelector("#commandPanel .lastCommand"); + if (!panel || document.getElementById("commandPresetSelect")) return; + const group = document.createElement("div"); + group.className = "commandGroup commandPresetGroup"; + group.innerHTML = ` +
Готовые команды
+
+ + +
+
`; + panel.insertBefore(group, lastCommand || null); + const select = document.getElementById("commandPresetSelect"); + const sendButton = document.getElementById("commandPresetSend"); + const note = document.getElementById("commandPresetNote"); + + const updateNote = () => { + const preset = commandPresets[Number(select?.value) || 0]; + if (note) note.textContent = preset.note; + }; + const updateAvailability = () => { + const connected = Boolean(window.OpenMCCSerial?.getState?.()?.connected); + if (sendButton) sendButton.disabled = !connected; + }; + + select?.addEventListener("change", updateNote); + sendButton?.addEventListener("click", async () => { + const preset = commandPresets[Number(select?.value) || 0]; + try { + await window.OpenMCCUI.transmitCommand(preset.command, preset.params); + } catch { + // transmitCommand already writes the error to the event log. + } + }); + window.addEventListener("openmcc:serial-connected", updateAvailability); + window.addEventListener("openmcc:serial-disconnected", updateAvailability); + updateNote(); + updateAvailability(); + } + function showFirstRunToast() { if (localStorage.getItem(FIRST_RUN_KEY) === "1") return; - const toast = document.createElement("div"); toast.className = "openmccToast"; - toast.innerHTML = ` - OpenMCC v${HELP_VERSION} - Интерфейс обновлён. Наведите курсор на элементы для подсказок или нажмите F1, чтобы открыть краткую инструкцию. -
- `; - + toast.innerHTML = `OpenMCC v${HELP_VERSION}Добавлены схемы подключения и RF-настройки CC1101/E32, готовые команды и обновлённая 3D-модель CubeSat.
`; toast.querySelector("button")?.addEventListener("click", () => { localStorage.setItem(FIRST_RUN_KEY, "1"); toast.remove(); }); - document.body.appendChild(toast); } function initialize() { + loadEnhancedStyles(); + updateBuildBadge(); + initializeRadioPlanner(); + initializeCommandPresets(); addTooltips(); initializeTooltipSystem(); initializeNavigation(); initializeHelpDialog(); - initializeRadioPlanner(); showFirstRunToast(); - - window.dispatchEvent(new CustomEvent("openmcc:help-ready", { - detail: { version: HELP_VERSION }, - })); + window.dispatchEvent(new CustomEvent("openmcc:help-ready", { detail: { version: HELP_VERSION } })); } - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initialize, { once: true }); - } else { - initialize(); - } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initialize, { once: true }); + else initialize(); })(); diff --git a/js/satellite.js b/js/satellite.js index f493441..69aacf4 100644 --- a/js/satellite.js +++ b/js/satellite.js @@ -1,1463 +1,414 @@ "use strict"; import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; -import { - OrbitControls -} from "three/addons/controls/OrbitControls.js"; - - -/* ============================================================ - OpenMCC - Open Mission Control Center - - Module: satellite.js - Version: 0.1.0 - - Назначение: - - отображение программной модели CubeSat; - - управление камерой; - - отображение ориентации; - - обработка ROLL, PITCH и YAW; - - автоматическое демонстрационное вращение. - ============================================================ */ - - -const SATELLITE_CONFIG = Object.freeze({ - - version: - "0.1.0", - - background: - 0x07101e, - - cameraDistance: - 7.2, - - autoRotationSpeed: - 0.25, - - orientationSmoothing: - 0.10 - -}); - - -const state = { - - initialized: - false, - - autoRotation: - true, - - telemetryReceived: - false, - - scene: - null, - - camera: - null, - - renderer: - null, - - controls: - null, - - satelliteRoot: - null, - - clock: - null, - - animationFrame: - null, - - currentOrientation: - { - roll: 0, - pitch: 0, - yaw: 0 - }, - - targetOrientation: - { - roll: 0, - pitch: 0, - yaw: 0 - } - -}; - - -const elements = { - - viewport: - null, - - canvas: - null, - - status: - null, - - resetViewButton: - null, - - autoRotationButton: - null, - - roll: - null, - - pitch: - null, - - yaw: - null - -}; - - -function writeLog( - message, - type = "info", - metadata = null -) { - - if ( - window.OpenMCCLogger && - typeof window.OpenMCCLogger.write === "function" - ) { - - window.OpenMCCLogger.write( - message, - type, - "SATELLITE", - metadata - ); - - return; - - } - - console.log( - `[OpenMCC Satellite] ${message}` - ); - -} - - -function cacheElements() { - - elements.viewport = - document.getElementById( - "satelliteViewport" - ); - - elements.canvas = - document.getElementById( - "satelliteCanvas" - ); - - elements.status = - document.getElementById( - "satelliteStatus" - ); - - elements.resetViewButton = - document.getElementById( - "resetSatelliteView" - ); - - elements.autoRotationButton = - document.getElementById( - "toggleSatelliteRotation" - ); - - elements.roll = - document.getElementById( - "orientationRoll" - ); - - elements.pitch = - document.getElementById( - "orientationPitch" - ); - - elements.yaw = - document.getElementById( - "orientationYaw" - ); - -} - - -function setStatus( - text, - type = "ready" -) { - - if (!elements.status) { - - return; - - } - - elements.status.textContent = - text; - - elements.status.classList.remove( - "ready", - "error" - ); - - elements.status.classList.add( - type - ); - -} - - -function createScene() { - - state.scene = - new THREE.Scene(); - - state.scene.background = - null; - - state.camera = - new THREE.PerspectiveCamera( - 38, - 1, - 0.1, - 100 - ); - - state.camera.position.set( - 5.4, - 3.8, - SATELLITE_CONFIG.cameraDistance - ); - - - state.renderer = - new THREE.WebGLRenderer({ - - canvas: - elements.canvas, - - antialias: - true, - - alpha: - true, - - powerPreference: - "high-performance" - - }); - - state.renderer.setPixelRatio( - Math.min( - window.devicePixelRatio, - 2 - ) - ); - - state.renderer.outputColorSpace = - THREE.SRGBColorSpace; - - state.renderer.shadowMap.enabled = - true; - - state.renderer.shadowMap.type = - THREE.PCFSoftShadowMap; - - - state.controls = - new OrbitControls( - state.camera, - elements.canvas - ); - - state.controls.enableDamping = - true; - - state.controls.dampingFactor = - 0.075; - - state.controls.enablePan = - false; - - state.controls.minDistance = - 4.2; - - state.controls.maxDistance = - 13; - - state.controls.target.set( - 0, - 0, - 0 - ); - - - state.clock = - new THREE.Clock(); - -} - - -function createLights() { - - const ambientLight = - new THREE.HemisphereLight( - 0x94cfff, - 0x08101d, - 1.35 - ); - - state.scene.add( - ambientLight - ); - - - const mainLight = - new THREE.DirectionalLight( - 0xffffff, - 3.2 - ); - - mainLight.position.set( - 5, - 7, - 8 - ); - - mainLight.castShadow = - true; - - state.scene.add( - mainLight - ); - - - const cyanLight = - new THREE.PointLight( - 0x00d9ff, - 8, - 14 - ); - - cyanLight.position.set( - -4, - 1, - 3 - ); - - state.scene.add( - cyanLight - ); - - - const rimLight = - new THREE.DirectionalLight( - 0x315dff, - 1.7 - ); - - rimLight.position.set( - -5, - 2, - -7 - ); - - state.scene.add( - rimLight - ); - -} - - -function createMaterial( - color, - metalness = 0.5, - roughness = 0.35 -) { - - return new THREE.MeshStandardMaterial({ - - color, - - metalness, - - roughness - - }); - -} - - -function createCubeSatBody() { - - const root = - new THREE.Group(); - - root.name = - "CubeSatRoot"; - - - const frameMaterial = - createMaterial( - 0xaeb9c8, - 0.82, - 0.24 - ); - - - const bodyMaterial = - createMaterial( - 0x202c3c, - 0.55, - 0.34 - ); - - - const darkMaterial = - createMaterial( - 0x09111d, - 0.68, - 0.28 - ); - - - const goldMaterial = - createMaterial( - 0xd4a52f, - 0.72, - 0.28 - ); - - - const solarCellMaterial = - new THREE.MeshStandardMaterial({ - - color: - 0x123f79, - - emissive: - 0x041833, - - emissiveIntensity: - 0.75, - - metalness: - 0.45, - - roughness: - 0.28 - - }); - - - const body = - new THREE.Mesh( - - new THREE.BoxGeometry( - 1.75, - 2.75, - 1.75 - ), - - bodyMaterial - - ); - - body.castShadow = - true; - - body.receiveShadow = - true; - - root.add(body); - - - const railGeometry = - new THREE.BoxGeometry( - 0.13, - 3.15, - 0.13 - ); - - const railCoordinates = [ - - [-0.94, -0.94], - [-0.94, 0.94], - [0.94, -0.94], - [0.94, 0.94] - - ]; - - railCoordinates.forEach( - ([x, z]) => { - - const rail = - new THREE.Mesh( - railGeometry, - frameMaterial - ); - - rail.position.set( - x, - 0, - z - ); - - rail.castShadow = - true; - - root.add(rail); - - } - ); - - - const endPlateGeometry = - new THREE.BoxGeometry( - 1.98, - 0.12, - 1.98 - ); - - [-1.48, 1.48].forEach(y => { - - const plate = - new THREE.Mesh( - endPlateGeometry, - frameMaterial - ); - - plate.position.y = - y; - - plate.castShadow = - true; - - root.add(plate); - - }); - - - const frontPanel = - new THREE.Mesh( - - new THREE.BoxGeometry( - 1.48, - 2.35, - 0.055 - ), - - darkMaterial - - ); - - frontPanel.position.z = - 0.895; - - root.add(frontPanel); - - - const sensor = - new THREE.Mesh( - - new THREE.CylinderGeometry( - 0.22, - 0.22, - 0.12, - 32 - ), - - darkMaterial - - ); - - sensor.rotation.x = - Math.PI / 2; - - sensor.position.set( - 0, - 0.55, - 0.97 - ); - - root.add(sensor); - - - const sensorGlass = - new THREE.Mesh( - - new THREE.CylinderGeometry( - 0.14, - 0.14, - 0.13, - 32 - ), - - new THREE.MeshStandardMaterial({ - - color: - 0x38b9ff, - - emissive: - 0x0076a8, - - emissiveIntensity: - 1.1, - - metalness: - 0.15, - - roughness: - 0.16 - - }) - - ); - - sensorGlass.rotation.x = - Math.PI / 2; - - sensorGlass.position.set( - 0, - 0.55, - 1.035 - ); - - root.add(sensorGlass); - - - for (let row = 0; row < 5; row++) { - - for (let column = 0; column < 3; column++) { - - const cell = - new THREE.Mesh( - - new THREE.BoxGeometry( - 0.39, - 0.31, - 0.035 - ), - - solarCellMaterial - - ); - - cell.position.set( - -0.46 + column * 0.46, - -0.82 + row * 0.40, - 0.94 - ); - - root.add(cell); - - } - - } - - - const antennaBase = - new THREE.Mesh( - - new THREE.CylinderGeometry( - 0.18, - 0.24, - 0.16, - 24 - ), - - goldMaterial - - ); - - antennaBase.position.y = - 1.62; - - root.add(antennaBase); - - - const antenna = - new THREE.Mesh( - - new THREE.CylinderGeometry( - 0.018, - 0.018, - 1.65, - 12 - ), - - goldMaterial - - ); - - antenna.position.set( - 0.2, - 2.42, - 0 - ); - - antenna.rotation.z = - -0.10; - - root.add(antenna); - - - const sidePanels = - createSolarPanels( - solarCellMaterial, - frameMaterial - ); - - root.add( - sidePanels - ); - - - const axisHelper = - new THREE.AxesHelper( - 2.7 - ); - - axisHelper.material.transparent = - true; - - axisHelper.material.opacity = - 0.8; - - root.add( - axisHelper - ); - - - root.rotation.set( - 0.25, - -0.45, - 0.08 - ); - - return root; - -} - - -function createSolarPanels( - solarMaterial, - frameMaterial -) { - - const panelGroup = - new THREE.Group(); - - - const panelGeometry = - new THREE.BoxGeometry( - 2.15, - 1.32, - 0.075 - ); - - - [-1, 1].forEach(side => { - - const hinge = - new THREE.Group(); - - hinge.position.x = - side * 1.04; - - - const support = - new THREE.Mesh( - - new THREE.BoxGeometry( - 0.35, - 0.16, - 0.14 - ), - - frameMaterial - - ); - - support.position.x = - side * 0.16; - - hinge.add(support); - - - const panel = - new THREE.Mesh( - panelGeometry, - solarMaterial - ); - - panel.position.x = - side * 1.24; - - panel.castShadow = - true; - - hinge.add(panel); - - - for (let row = 0; row < 3; row++) { - - for (let column = 0; column < 5; column++) { - - const separator = - new THREE.Mesh( - - new THREE.BoxGeometry( - 0.35, - 0.32, - 0.015 - ), - - new THREE.MeshStandardMaterial({ - - color: - 0x1b65a6, - - emissive: - 0x061a36, - - emissiveIntensity: - 0.45, - - metalness: - 0.35, - - roughness: - 0.30 +/* ============================================================ + OpenMCC — CubeSat 3D view + Version 0.3.0 + Generic educational 2U CubeSat with fixed body solar panels + and a deployed tape-measure dipole antenna. + ============================================================ */ - }) +const SATELLITE_CONFIG = Object.freeze({ + version: "0.3.0", + cameraDistance: 7.8, + autoRotationSpeed: 0.22, + orientationSmoothing: 0.10, +}); - ); +const state = { + initialized: false, + autoRotation: true, + telemetryReceived: false, + scene: null, + camera: null, + renderer: null, + controls: null, + satelliteRoot: null, + clock: null, + animationFrame: null, + currentOrientation: { roll: 0, pitch: 0, yaw: 0 }, + targetOrientation: { roll: 0, pitch: 0, yaw: 0 }, +}; - separator.position.set( - side * ( - 0.48 + - column * 0.40 - ), - -0.43 + - row * 0.43, - 0.047 - ); +const elements = {}; - hinge.add(separator); +function writeLog(message, type = "info", metadata = null) { + if (window.OpenMCCLogger?.write) { + window.OpenMCCLogger.write(message, type, "SATELLITE", metadata); + return; + } + console.log(`[OpenMCC Satellite] ${message}`); +} - } +function cacheElements() { + elements.viewport = document.getElementById("satelliteViewport"); + elements.canvas = document.getElementById("satelliteCanvas"); + elements.status = document.getElementById("satelliteStatus"); + elements.resetViewButton = document.getElementById("resetSatelliteView"); + elements.autoRotationButton = document.getElementById("toggleSatelliteRotation"); + elements.roll = document.getElementById("orientationRoll"); + elements.pitch = document.getElementById("orientationPitch"); + elements.yaw = document.getElementById("orientationYaw"); +} - } +function setStatus(text, type = "ready") { + if (!elements.status) return; + elements.status.textContent = text; + elements.status.classList.remove("ready", "error"); + elements.status.classList.add(type); +} +function standardMaterial(color, metalness = 0.5, roughness = 0.35) { + return new THREE.MeshStandardMaterial({ color, metalness, roughness }); +} - panelGroup.add( - hinge - ); +function box(size, material, position = [0, 0, 0]) { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material); + mesh.position.set(...position); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} +function createScene() { + state.scene = new THREE.Scene(); + state.camera = new THREE.PerspectiveCamera(37, 1, 0.1, 100); + state.camera.position.set(5.5, 3.9, SATELLITE_CONFIG.cameraDistance); + + state.renderer = new THREE.WebGLRenderer({ + canvas: elements.canvas, + antialias: true, + alpha: true, + powerPreference: "high-performance", }); - - return panelGroup; - + state.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + state.renderer.outputColorSpace = THREE.SRGBColorSpace; + state.renderer.shadowMap.enabled = true; + state.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + state.controls = new OrbitControls(state.camera, elements.canvas); + state.controls.enableDamping = true; + state.controls.dampingFactor = 0.075; + state.controls.enablePan = false; + state.controls.minDistance = 4.8; + state.controls.maxDistance = 15; + state.controls.target.set(0, 0, 0); + state.clock = new THREE.Clock(); } +function createLights() { + state.scene.add(new THREE.HemisphereLight(0xa9d8ff, 0x07101b, 1.45)); -function createEnvironment() { - - const grid = - new THREE.GridHelper( - 18, - 36, - 0x174567, - 0x10243a - ); - - grid.position.y = - -2.25; - - grid.material.transparent = - true; - - grid.material.opacity = - 0.28; - - state.scene.add(grid); - - - const starsGeometry = - new THREE.BufferGeometry(); - - const starCount = - 280; + const main = new THREE.DirectionalLight(0xffffff, 3.5); + main.position.set(5, 7, 8); + main.castShadow = true; + state.scene.add(main); - const positions = - new Float32Array( - starCount * 3 - ); + const fill = new THREE.PointLight(0x00d9ff, 6, 15); + fill.position.set(-4, 1, 4); + state.scene.add(fill); - for ( - let index = 0; - index < positions.length; - index += 3 - ) { + const rim = new THREE.DirectionalLight(0x315dff, 1.8); + rim.position.set(-5, 3, -7); + state.scene.add(rim); +} - positions[index] = - (Math.random() - 0.5) * 18; +function addSolarCellsToZFace(root, z, cellMaterial, frameMaterial) { + const panel = box([1.52, 2.62, 0.055], frameMaterial, [0, 0, z]); + root.add(panel); - positions[index + 1] = - (Math.random() - 0.5) * 12; + for (let row = 0; row < 6; row++) { + for (let column = 0; column < 3; column++) { + const cell = box( + [0.42, 0.34, 0.035], + cellMaterial, + [-0.48 + column * 0.48, -1.02 + row * 0.41, z + Math.sign(z) * 0.047] + ); + root.add(cell); + } + } +} - positions[index + 2] = - -3 - Math.random() * 10; +function addSolarCellsToXFace(root, x, cellMaterial, frameMaterial) { + const panel = box([0.055, 2.62, 1.52], frameMaterial, [x, 0, 0]); + root.add(panel); + for (let row = 0; row < 6; row++) { + for (let column = 0; column < 3; column++) { + const cell = box( + [0.035, 0.34, 0.42], + cellMaterial, + [x + Math.sign(x) * 0.047, -1.02 + row * 0.41, -0.48 + column * 0.48] + ); + root.add(cell); + } } +} - starsGeometry.setAttribute( - "position", - new THREE.BufferAttribute( - positions, - 3 - ) - ); - +function addCornerFasteners(root, y, frameMaterial) { + const geometry = new THREE.CylinderGeometry(0.045, 0.045, 0.025, 16); + [[-0.72,-0.72],[-0.72,0.72],[0.72,-0.72],[0.72,0.72]].forEach(([x,z]) => { + const screw = new THREE.Mesh(geometry, frameMaterial); + screw.position.set(x, y, z); + root.add(screw); + }); +} - const stars = - new THREE.Points( +function createTapeDipole(root, springMaterial, darkMaterial, frameMaterial) { + const topY = 1.69; + + const deployer = box([0.72, 0.22, 0.54], darkMaterial, [0, topY, 0.12]); + root.add(deployer); + + const cover = box([0.62, 0.035, 0.44], frameMaterial, [0, topY + 0.13, 0.12]); + root.add(cover); + + const slotMaterial = standardMaterial(0x02050a, 0.15, 0.65); + root.add(box([0.055, 0.09, 0.18], slotMaterial, [0.37, topY, 0.12])); + root.add(box([0.055, 0.09, 0.18], slotMaterial, [-0.37, topY, 0.12])); + + const armLength = 1.72; + const armThickness = 0.025; + const armWidth = 0.105; + const right = box([armLength, armThickness, armWidth], springMaterial, [0.38 + armLength / 2, topY + 0.015, 0.12]); + const left = box([armLength, armThickness, armWidth], springMaterial, [-0.38 - armLength / 2, topY + 0.015, 0.12]); + right.rotation.z = 0.015; + left.rotation.z = -0.015; + root.add(right, left); + + const feedMaterial = standardMaterial(0xc99c36, 0.78, 0.25); + const feedGeometry = new THREE.CylinderGeometry(0.055, 0.055, 0.08, 20); + const feedRight = new THREE.Mesh(feedGeometry, feedMaterial); + const feedLeft = new THREE.Mesh(feedGeometry, feedMaterial); + feedRight.position.set(0.40, topY + 0.02, 0.12); + feedLeft.position.set(-0.40, topY + 0.02, 0.12); + feedRight.rotation.z = Math.PI / 2; + feedLeft.rotation.z = Math.PI / 2; + root.add(feedRight, feedLeft); +} - starsGeometry, +function createCubeSatBody() { + const root = new THREE.Group(); + root.name = "CubeSat2UTapeDipole"; + + const frameMaterial = standardMaterial(0xb8c2cd, 0.84, 0.22); + const bodyMaterial = standardMaterial(0x151e2a, 0.58, 0.36); + const panelFrameMaterial = standardMaterial(0x0a111b, 0.55, 0.34); + const darkMaterial = standardMaterial(0x070c13, 0.68, 0.30); + const springMaterial = standardMaterial(0xb7ad83, 0.72, 0.25); + const solarCellMaterial = new THREE.MeshStandardMaterial({ + color: 0x0c3b73, + emissive: 0x03162c, + emissiveIntensity: 0.75, + metalness: 0.42, + roughness: 0.26, + }); - new THREE.PointsMaterial({ + const body = box([1.78, 3.0, 1.78], bodyMaterial); + root.add(body); - color: - 0x7ecfff, + const railGeometry = new THREE.BoxGeometry(0.12, 3.36, 0.12); + [[-0.94,-0.94],[-0.94,0.94],[0.94,-0.94],[0.94,0.94]].forEach(([x,z]) => { + const rail = new THREE.Mesh(railGeometry, frameMaterial); + rail.position.set(x, 0, z); + rail.castShadow = true; + root.add(rail); + }); - size: - 0.025, + root.add(box([2.00, 0.11, 2.00], frameMaterial, [0, -1.56, 0])); + root.add(box([2.00, 0.11, 2.00], frameMaterial, [0, 1.56, 0])); + addCornerFasteners(root, -1.625, frameMaterial); + addCornerFasteners(root, 1.625, frameMaterial); - transparent: - true, + addSolarCellsToZFace(root, 0.905, solarCellMaterial, panelFrameMaterial); + addSolarCellsToZFace(root, -0.905, solarCellMaterial, panelFrameMaterial); + addSolarCellsToXFace(root, 0.905, solarCellMaterial, panelFrameMaterial); + addSolarCellsToXFace(root, -0.905, solarCellMaterial, panelFrameMaterial); - opacity: - 0.55 + const cameraBody = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.23, 0.13, 28), darkMaterial); + cameraBody.rotation.x = Math.PI / 2; + cameraBody.position.set(0, 0.46, 0.99); + root.add(cameraBody); - }) + const glass = new THREE.Mesh( + new THREE.CylinderGeometry(0.12, 0.12, 0.14, 28), + new THREE.MeshStandardMaterial({ color: 0x3fbfff, emissive: 0x006e9f, emissiveIntensity: 0.9, metalness: 0.12, roughness: 0.12 }) + ); + glass.rotation.x = Math.PI / 2; + glass.position.set(0, 0.46, 1.055); + root.add(glass); - ); + createTapeDipole(root, springMaterial, darkMaterial, frameMaterial); - state.scene.add(stars); + const axisHelper = new THREE.AxesHelper(2.45); + axisHelper.material.transparent = true; + axisHelper.material.opacity = 0.62; + root.add(axisHelper); + root.rotation.set(0.22, -0.45, 0.07); + return root; } +function createEnvironment() { + const grid = new THREE.GridHelper(18, 36, 0x174567, 0x10243a); + grid.position.y = -2.45; + grid.material.transparent = true; + grid.material.opacity = 0.22; + state.scene.add(grid); -function resizeRenderer() { - - if ( - !elements.viewport || - !state.renderer || - !state.camera - ) { - - return; - - } - - const width = - elements.viewport.clientWidth; - - const height = - elements.viewport.clientHeight; - - if ( - width <= 0 || - height <= 0 - ) { - - return; - + const starsGeometry = new THREE.BufferGeometry(); + const starCount = 320; + const positions = new Float32Array(starCount * 3); + for (let i = 0; i < positions.length; i += 3) { + positions[i] = (Math.random() - 0.5) * 20; + positions[i + 1] = (Math.random() - 0.5) * 14; + positions[i + 2] = -3 - Math.random() * 12; } + starsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + state.scene.add(new THREE.Points(starsGeometry, new THREE.PointsMaterial({ color: 0x7ecfff, size: 0.025, transparent: true, opacity: 0.52 }))); +} - state.renderer.setSize( - width, - height, - false - ); - - state.camera.aspect = - width / height; - +function resizeRenderer() { + if (!elements.viewport || !state.renderer || !state.camera) return; + const width = elements.viewport.clientWidth; + const height = elements.viewport.clientHeight; + if (width <= 0 || height <= 0) return; + state.renderer.setSize(width, height, false); + state.camera.aspect = width / height; state.camera.updateProjectionMatrix(); - } - function degreesToRadians(value) { - - return THREE.MathUtils.degToRad( - Number(value) || 0 - ); - + return THREE.MathUtils.degToRad(Number(value) || 0); } - function updateOrientationDisplay() { - - if (elements.roll) { - - elements.roll.textContent = - `${state.targetOrientation.roll.toFixed(1)}°`; - - } - - if (elements.pitch) { - - elements.pitch.textContent = - `${state.targetOrientation.pitch.toFixed(1)}°`; - - } - - if (elements.yaw) { - - elements.yaw.textContent = - `${state.targetOrientation.yaw.toFixed(1)}°`; - - } - + if (elements.roll) elements.roll.textContent = `${state.targetOrientation.roll.toFixed(1)}°`; + if (elements.pitch) elements.pitch.textContent = `${state.targetOrientation.pitch.toFixed(1)}°`; + if (elements.yaw) elements.yaw.textContent = `${state.targetOrientation.yaw.toFixed(1)}°`; } - function processTelemetry(telemetry) { - - if ( - !telemetry || - typeof telemetry !== "object" - ) { - - return; - - } - - let orientationUpdated = - false; - - if ( - Number.isFinite( - Number(telemetry.ROLL) - ) - ) { - - state.targetOrientation.roll = - Number(telemetry.ROLL); - - orientationUpdated = - true; - - } - - if ( - Number.isFinite( - Number(telemetry.PITCH) - ) - ) { - - state.targetOrientation.pitch = - Number(telemetry.PITCH); - - orientationUpdated = - true; - - } - - if ( - Number.isFinite( - Number(telemetry.YAW) - ) - ) { - - state.targetOrientation.yaw = - Number(telemetry.YAW); - - orientationUpdated = - true; - + if (!telemetry || typeof telemetry !== "object") return; + let updated = false; + for (const [key, property] of [["ROLL","roll"],["PITCH","pitch"],["YAW","yaw"]]) { + if (Number.isFinite(Number(telemetry[key]))) { + state.targetOrientation[property] = Number(telemetry[key]); + updated = true; + } } - - if (orientationUpdated) { - - state.telemetryReceived = - true; - - state.autoRotation = - false; - + if (updated) { + state.telemetryReceived = true; + state.autoRotation = false; updateAutoRotationButton(); - updateOrientationDisplay(); - } - } - -function interpolateAngle( - current, - target, - coefficient -) { - - let difference = - target - current; - - while (difference > 180) { - - difference -= 360; - - } - - while (difference < -180) { - - difference += 360; - - } - - return current + - difference * coefficient; - +function interpolateAngle(current, target, coefficient) { + let difference = target - current; + while (difference > 180) difference -= 360; + while (difference < -180) difference += 360; + return current + difference * coefficient; } - -function updateSatelliteRotation( - deltaTime -) { - - if (!state.satelliteRoot) { - +function updateSatelliteRotation(deltaTime) { + if (!state.satelliteRoot) return; + if (state.autoRotation && !state.telemetryReceived) { + state.satelliteRoot.rotation.y += deltaTime * SATELLITE_CONFIG.autoRotationSpeed; return; - - } - - if ( - state.autoRotation && - !state.telemetryReceived - ) { - - state.satelliteRoot.rotation.y += - deltaTime * - SATELLITE_CONFIG.autoRotationSpeed; - - return; - } - state.currentOrientation.roll = - interpolateAngle( - state.currentOrientation.roll, - state.targetOrientation.roll, - SATELLITE_CONFIG.orientationSmoothing - ); - - state.currentOrientation.pitch = - interpolateAngle( - state.currentOrientation.pitch, - state.targetOrientation.pitch, - SATELLITE_CONFIG.orientationSmoothing - ); - - state.currentOrientation.yaw = - interpolateAngle( - state.currentOrientation.yaw, - state.targetOrientation.yaw, - SATELLITE_CONFIG.orientationSmoothing - ); + state.currentOrientation.roll = interpolateAngle(state.currentOrientation.roll, state.targetOrientation.roll, SATELLITE_CONFIG.orientationSmoothing); + state.currentOrientation.pitch = interpolateAngle(state.currentOrientation.pitch, state.targetOrientation.pitch, SATELLITE_CONFIG.orientationSmoothing); + state.currentOrientation.yaw = interpolateAngle(state.currentOrientation.yaw, state.targetOrientation.yaw, SATELLITE_CONFIG.orientationSmoothing); state.satelliteRoot.rotation.set( - - degreesToRadians( - state.currentOrientation.pitch - ), - - degreesToRadians( - state.currentOrientation.yaw - ), - - degreesToRadians( - state.currentOrientation.roll - ), - + degreesToRadians(state.currentOrientation.pitch), + degreesToRadians(state.currentOrientation.yaw), + degreesToRadians(state.currentOrientation.roll), "YXZ" - ); - } - function animate() { - - state.animationFrame = - requestAnimationFrame( - animate - ); - - const deltaTime = - Math.min( - state.clock.getDelta(), - 0.05 - ); - - updateSatelliteRotation( - deltaTime - ); - + state.animationFrame = requestAnimationFrame(animate); + const deltaTime = Math.min(state.clock.getDelta(), 0.05); + updateSatelliteRotation(deltaTime); state.controls.update(); - - state.renderer.render( - state.scene, - state.camera - ); - + state.renderer.render(state.scene, state.camera); } - function resetCameraView() { - - state.camera.position.set( - 5.4, - 3.8, - SATELLITE_CONFIG.cameraDistance - ); - - state.controls.target.set( - 0, - 0, - 0 - ); - + state.camera.position.set(5.5, 3.9, SATELLITE_CONFIG.cameraDistance); + state.controls.target.set(0, 0, 0); state.controls.update(); - - writeLog( - "Положение 3D-камеры восстановлено", - "info" - ); - + writeLog("Положение 3D-камеры восстановлено", "info"); } - function updateAutoRotationButton() { - - if (!elements.autoRotationButton) { - - return; - - } - - elements.autoRotationButton.textContent = - state.autoRotation - ? "Остановить вращение" - : "Автовращение"; - + if (!elements.autoRotationButton) return; + elements.autoRotationButton.textContent = state.autoRotation ? "Остановить вращение" : "Автовращение"; } - function toggleAutoRotation() { - - state.autoRotation = - !state.autoRotation; - - if (state.autoRotation) { - - state.telemetryReceived = - false; - - } - + state.autoRotation = !state.autoRotation; + if (state.autoRotation) state.telemetryReceived = false; updateAutoRotationButton(); - - writeLog( - state.autoRotation - ? "Автоматическое вращение модели включено" - : "Автоматическое вращение модели выключено", - "info" - ); - + writeLog(state.autoRotation ? "Автоматическое вращение модели включено" : "Автоматическое вращение модели выключено", "info"); } - function registerEvents() { - - window.addEventListener( - "resize", - resizeRenderer - ); - - - window.addEventListener( - "openmcc:telemetry", - event => { - - processTelemetry( - event.detail - ); - - } - ); - - - elements.resetViewButton - ?.addEventListener( - "click", - resetCameraView - ); - - - elements.autoRotationButton - ?.addEventListener( - "click", - toggleAutoRotation - ); - - - const resizeObserver = - new ResizeObserver( - resizeRenderer - ); - - resizeObserver.observe( - elements.viewport - ); - + window.addEventListener("resize", resizeRenderer); + window.addEventListener("openmcc:telemetry", (event) => processTelemetry(event.detail)); + elements.resetViewButton?.addEventListener("click", resetCameraView); + elements.autoRotationButton?.addEventListener("click", toggleAutoRotation); + const resizeObserver = new ResizeObserver(resizeRenderer); + resizeObserver.observe(elements.viewport); } - function initialize() { - - if (state.initialized) { - - return; - - } - + if (state.initialized) return; try { - cacheElements(); - - if ( - !elements.viewport || - !elements.canvas - ) { - - throw new Error( - "Контейнер 3D-модели не найден" - ); - - } - + if (!elements.viewport || !elements.canvas) throw new Error("Контейнер 3D-модели не найден"); createScene(); - createLights(); - - state.satelliteRoot = - createCubeSatBody(); - - state.scene.add( - state.satelliteRoot - ); - + state.satelliteRoot = createCubeSatBody(); + state.scene.add(state.satelliteRoot); createEnvironment(); - registerEvents(); - resizeRenderer(); - resetCameraView(); - updateAutoRotationButton(); - updateOrientationDisplay(); - - state.initialized = - true; - - setStatus( - "ГОТОВ", - "ready" - ); - - writeLog( - `Модуль 3D-модели v${SATELLITE_CONFIG.version} загружен`, - "success" - ); - + state.initialized = true; + setStatus("2U · DIPOLE", "ready"); + writeLog(`3D-модель CubeSat v${SATELLITE_CONFIG.version}: fixed solar panels + tape-measure dipole`, "success"); animate(); - - } - catch (error) { - - setStatus( - "ОШИБКА", - "error" - ); - - writeLog( - `Ошибка инициализации 3D-модели: ${error.message}`, - "error" - ); - + } catch (error) { + setStatus("ОШИБКА", "error"); + writeLog(`Ошибка инициализации 3D-модели: ${error.message}`, "error"); console.error(error); - } - } - window.OpenMCCSatellite = Object.freeze({ - processTelemetry, - resetCameraView, - toggleAutoRotation, - getState() { - return { - - initialized: - state.initialized, - - autoRotation: - state.autoRotation, - - telemetryReceived: - state.telemetryReceived, - - currentOrientation: - { - ...state.currentOrientation - }, - - targetOrientation: - { - ...state.targetOrientation - } - + initialized: state.initialized, + autoRotation: state.autoRotation, + telemetryReceived: state.telemetryReceived, + currentOrientation: { ...state.currentOrientation }, + targetOrientation: { ...state.targetOrientation }, + model: "generic-2u-tape-dipole", }; - - } - + }, }); - -initialize(); \ No newline at end of file +initialize(); diff --git a/package.json b/package.json index 1bcff8e..eca3521 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmcc", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "Open Mission Control Center desktop application for educational CubeSat ground stations", "main": "desktop/main.cjs",