From 6eea3d8ec0fac4434e4706514f51ddb9e720fbbd Mon Sep 17 00:00:00 2001 From: Haroon Qureshi Date: Mon, 22 Sep 2025 05:01:31 -0700 Subject: [PATCH] Initial implementation of toolbox-based "Studio" app. PiperOrigin-RevId: 809966748 Change-Id: Ic6333259ca1d112a2e42d47ccc44326bfcea3bdd --- src/experimental/studio/app.cc | 1720 +++++++++++++++++++++++++++++++ src/experimental/studio/app.h | 194 ++++ src/experimental/studio/main.cc | 66 ++ 3 files changed, 1980 insertions(+) create mode 100644 src/experimental/studio/app.cc create mode 100644 src/experimental/studio/app.h create mode 100644 src/experimental/studio/main.cc diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc new file mode 100644 index 00000000..b26e5e8b --- /dev/null +++ b/src/experimental/studio/app.cc @@ -0,0 +1,1720 @@ +// Copyright 2025 DeepMind Technologies Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "experimental/studio/app.h" + +#ifndef USE_FILAMENT_RENDERING +#define USE_FILAMENT_RENDERING 0 +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/dear_imgui/imgui.h" +#include "third_party/implot/implot.h" +#include +#include "experimental/toolbox/helpers.h" +#include "experimental/toolbox/imgui_widgets.h" +#include "experimental/toolbox/interaction.h" +#include "experimental/toolbox/physics.h" +#include "experimental/toolbox/renderer.h" +#include "experimental/toolbox/window.h" + +#if USE_FILAMENT_RENDERING +#include "third_party/mujoco/google/filament/render_context_filament.h" +#else +#include "third_party/dear_imgui/backends/imgui_impl_opengl2.h" +#endif + +namespace mujoco::studio { + +// TO DO: +// - sensor graph +// - convergence profiler +// - solver iteration profiler +// - "passive" mode +// - async physics + +static void ToggleWindow(bool& window) { + window = !window; + ImGui::GetIO().WantSaveIniSettings = true; +} + +static void ShowPopup(bool& popup) { popup = true; } + +// FontAwesome icon codes. +static constexpr const char* ICON_PLAY = "\xef\x81\x8b"; +static constexpr const char* ICON_PAUSE = "\xef\x81\x8c"; +static constexpr const char* ICON_DARKMODE = "\xef\x86\x86"; +static constexpr const char* ICON_LIGHTMODE = "\xef\x86\x85"; + +// UI labels for mjtLabel. +static constexpr const char* kLabelNames[] = { + "None", "Body", "Joint", "Geom", "Site", "Camera", + "Light", "Tendon", "Actuator", "Constraint", "Flex", "Skin", + "Selection", "Sel Pnt", "Contact", "Force", "Island"}; + +// UI labels for mjtFrame. +static constexpr const char* kFrameNames[] = { + "None", "Body", "Geom", "Site", "Camera", "Light", "Contact", "World"}; + +using toolbox::ImGui_FileDialog; +using toolbox::ImGui_Input; +using toolbox::ImGui_InputN; +using toolbox::ImGui_IsChordJustPressed; +using toolbox::ImGui_Slider; +using toolbox::Toggle; +using toolbox::ToggleBit; +using toolbox::ToggleKind; + +App::App(int width, int height, std::string ini_path, + const toolbox::LoadAssetFn& load_asset_fn) + : ini_path_(std::move(ini_path)), load_asset_fn_(load_asset_fn) { +#if USE_FILAMENT_RENDERING + const toolbox::Window::Config config = + toolbox::Window::Config::kFilamentVulkan; +#else + const toolbox::Window::Config config = toolbox::Window::Config::kMujocoOpenGL; +#endif + + window_ = std::make_unique("MuJoCo Studio", width, height, + config, load_asset_fn); + + auto make_context_fn = [&](const mjModel* m, mjrContext* con) { +#if USE_FILAMENT_RENDERING + mjrFilamentConfig render_config; + mjr_defaultFilamentConfig(&render_config); + render_config.native_window = window_->GetNativeWindowHandle(); + render_config.load_asset = &App::LoadAssetCallback; + render_config.load_asset_user_data = this; + render_config.enable_gui = true; + mjr_makeFilamentContext(m, con, &render_config); +#else + mjr_makeContext(m, con, mjFONTSCALE_150); +#endif + }; + renderer_ = std::make_unique(make_context_fn); + + physics_ = std::make_unique( + [this](std::string_view model_file) { this->OnModelLoaded(model_file); }); + + mjv_defaultPerturb(&perturb_); + mjv_defaultCamera(&camera_); + mjv_defaultOption(&vis_options_); + + LoadSettings(); + ClearProfilerData(); + +#if USE_FILAMENT_RENDERING == 0 + ImGui_ImplOpenGL2_Init(); +#endif +} + +void App::LoadModel(std::string model_file) { + model_file_ = std::move(model_file); + physics_->LoadModel(model_file_); +} + +void App::OnModelLoaded(std::string_view model_file) { + renderer_->Init(Model()); + tmp_ = UiTempState(); + mjv_defaultOption(&vis_options_); + ClearProfilerData(); + if (model_file.empty()) { + window_->SetTitle("MuJoCo Studio"); + } else { + window_->SetTitle("MuJoCo Studio : " + std::string(model_file)); + } +} + +bool App::Update() { + const toolbox::Window::Status status = window_->NewFrame(); + +#if USE_FILAMENT_RENDERING == 0 + ImGui_ImplOpenGL2_NewFrame(); +#endif + + HandleMouseEvents(); + HandleKeyboardEvents(); + + // Check to see if a model was dropped on the window. + const std::string drop_file = window_->GetDropFile(); + if (!drop_file.empty()) { + LoadModel(drop_file); + } + + // Only update the simulation if a popup window is not open. Note that the + // simulation itself will only update if it is not paused. + if (!tmp_.modal_open) { + if (physics_->Update(&perturb_)) { + UpdateProfilerData(); + } + } + + return status == toolbox::Window::Status::kRunning; +} + +void App::Sync() { + renderer_->Sync(Model(), Data(), &perturb_, &camera_, &vis_options_); +} + +void App::Render() { + const float width = window_->GetWidth(); + const float height = window_->GetHeight(); + const float scale = window_->GetScale(); + + renderer_->Render(Model(), Data(), &perturb_, &camera_, &vis_options_, + width * scale, height * scale); + +#if USE_FILAMENT_RENDERING == 0 + ImGui::Render(); + ImGui_ImplOpenGL2_RenderDrawData(ImGui::GetDrawData()); +#endif + + // This call to EndFrame() is only needed if render_config.enable_gui is false + window_->EndFrame(); + window_->Present(); + + if (Data()) { + for (int i = 0; i < mjNTIMER; i++) { + Data()->timer[i].duration = 0; + Data()->timer[i].number = 0; + } + } +} + +void App::HandleMouseEvents() { + ::mujoco::toolbox::HandleMouseEvents(window_.get(), renderer_.get(), + physics_.get(), perturb_, vis_options_, + camera_, ui_.camera_idx); +} + +void App::ChangeSpeed(int delta) { + if (delta == 0) { + return; + } + + // logarithmically spaced real-time slow-down coefficients (percent) + // clang-format off + static constexpr std::array kPercentRealTime = { + 100.0, 80.0, 66.00, 50.0, 40.0, 33.00, 25.00, 20.0, 16.00, 13.00, + 10.0, 8.0, 6.60, 5.0, 4.0, 3.30, 2.50, 2.0, 1.60, 1.30, + 1.0, .8, .66, .5, .4, .33, .25, .2, .16, .13, + .1}; + // clang-format on + + tmp_.speed_index += delta; + tmp_.speed_index = + std::clamp(tmp_.speed_index, 0, kPercentRealTime.size() - 1); + + float speed = kPercentRealTime[tmp_.speed_index]; + physics_->GetStepControl().SetSpeed(speed); +} + +void App::HandleKeyboardEvents() { + if (ImGui::GetIO().WantCaptureKeyboard) { + return; + } + + constexpr auto ImGuiMode_CtrlShift = ImGuiMod_Ctrl | ImGuiMod_Shift; + + // Menu shortcuts. + if (ImGui_IsChordJustPressed(ImGuiKey_L | ImGuiMod_Ctrl)) { + ShowPopup(tmp_.load_popup); + } else if (ImGui_IsChordJustPressed(ImGuiKey_S | ImGuiMode_CtrlShift)) { + ShowPopup(tmp_.save_mjb_popup); + } else if (ImGui_IsChordJustPressed(ImGuiKey_S | ImGuiMod_Ctrl)) { + ShowPopup(tmp_.save_xml_popup); + } else if (ImGui_IsChordJustPressed(ImGuiKey_M | ImGuiMod_Ctrl)) { + ShowPopup(tmp_.print_model_popup); + } else if (ImGui_IsChordJustPressed(ImGuiKey_D | ImGuiMod_Ctrl)) { + ShowPopup(tmp_.print_data_popup); + } else if (ImGui_IsChordJustPressed(ImGuiKey_P | ImGuiMod_Ctrl)) { + ShowPopup(tmp_.save_screenshot_popup); + } else if (ImGui_IsChordJustPressed(ImGuiKey_C | ImGuiMod_Ctrl)) { + std::string keyframe = toolbox::KeyframeToString(Model(), Data(), false); + toolbox::MaybeSaveToClipboard(keyframe); + } else if (ImGui_IsChordJustPressed(ImGuiKey_R | ImGuiMod_Ctrl)) { + LoadModel(model_file_); + } else if (ImGui_IsChordJustPressed(ImGuiKey_Q | ImGuiMod_Ctrl)) { + tmp_.should_exit = true; + } else if (ImGui_IsChordJustPressed(ImGuiKey_A | ImGuiMod_Ctrl)) { + mjv_defaultFreeCamera(Model(), &camera_); + } else if (ImGui_IsChordJustPressed(ImGuiKey_F1)) { + ToggleWindow(tmp_.help); + } else if (ImGui_IsChordJustPressed(ImGuiKey_F2)) { + ToggleWindow(tmp_.info); + } else if (ImGui_IsChordJustPressed(ImGuiKey_Tab | ImGuiMod_Shift)) { + tmp_.show_ui_rhs = !tmp_.show_ui_rhs; + } else if (ImGui_IsChordJustPressed(ImGuiKey_Tab)) { + tmp_.show_ui_lhs = !tmp_.show_ui_lhs; + } + + // Window toggles for non-classic UI. + if (!ui_.classic_ui) { + if (ImGui_IsChordJustPressed(ImGuiKey_S | ImGuiMod_Alt)) { + ToggleWindow(ui_.simulation); + } else if (ImGui_IsChordJustPressed(ImGuiKey_W | ImGuiMod_Alt)) { + ToggleWindow(ui_.watch); + } else if (ImGui_IsChordJustPressed(ImGuiKey_P | ImGuiMod_Alt)) { + ToggleWindow(ui_.physics); + } else if (ImGui_IsChordJustPressed(ImGuiKey_R | ImGuiMod_Alt)) { + ToggleWindow(ui_.rendering); + } else if (ImGui_IsChordJustPressed(ImGuiKey_V | ImGuiMod_Alt)) { + ToggleWindow(ui_.visualization); + } else if (ImGui_IsChordJustPressed(ImGuiKey_G | ImGuiMod_Alt)) { + ToggleWindow(ui_.groups); + } else if (ImGui_IsChordJustPressed(ImGuiKey_J | ImGuiMod_Alt)) { + ToggleWindow(ui_.joints); + } else if (ImGui_IsChordJustPressed(ImGuiKey_C | ImGuiMod_Alt)) { + ToggleWindow(ui_.controls); + } + } + + if (ImGui_IsChordJustPressed(ImGuiKey_Minus)) { + ChangeSpeed(1); + } else if (ImGui_IsChordJustPressed(ImGuiKey_Equal)) { + ChangeSpeed(-1); + } else if (ImGui_IsChordJustPressed(ImGuiKey_LeftArrow)) { + if (physics_->IsPaused()) { + physics_->LoadHistory(ui_.scrub_idx - 1); + } + } else if (ImGui_IsChordJustPressed(ImGuiKey_RightArrow)) { + if (physics_->IsPaused()) { + if (ui_.scrub_idx == 0) { + physics_->RequestSingleStep(); + } else { + physics_->LoadHistory(ui_.scrub_idx + 1); + } + } + } + + // Camera wasd controls. + if (!ui_.classic_ui) { + mjvScene& scene = renderer_->GetScene(); + mjModel* model = Model(); + + // Move (dolly) forward/backward using W and S keys. + if (ImGui::IsKeyDown(ImGuiKey_W)) { + mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, 0, 0.01, &scene, &camera_); + } else if (ImGui::IsKeyDown(ImGuiKey_S)) { + mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, 0, -0.01, &scene, &camera_); + } + + // Strafe (truck) left/right using A dna D keys. + if (ImGui::IsKeyDown(ImGuiKey_A)) { + mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, -0.01, 0, &scene, &camera_); + } else if (ImGui::IsKeyDown(ImGuiKey_D)) { + mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, 0.01, 0, &scene, &camera_); + } + + // Move (pedestal) up/down using Q and E keys. + if (ImGui::IsKeyDown(ImGuiKey_Q)) { + mjv_moveCamera(model, mjMOUSE_MOVE_V_REL, 0, 0.01, &scene, &camera_); + } else if (ImGui::IsKeyDown(ImGuiKey_E)) { + mjv_moveCamera(model, mjMOUSE_MOVE_V_REL, 0, -0.01, &scene, &camera_); + } + } + + ::mujoco::toolbox::HandleKeyboardEvents( + window_.get(), renderer_.get(), physics_.get(), perturb_, vis_options_, + camera_, ui_.camera_idx); +} + +void App::LoadSettings() { + if (!ini_path_.empty()) { + std::string settings = toolbox::LoadText(ini_path_); + if (!settings.empty()) { + ui_.FromDict(toolbox::ReadIniSection(settings, "[Simulate][Data]")); + ImGui::LoadIniSettingsFromMemory(settings.data(), settings.size()); + } + } +} + +void App::SaveSettings() { + if (!ini_path_.empty()) { + std::string settings = ImGui::SaveIniSettingsToMemory(); + toolbox::AppendIniSection(settings, "[Simulate][Data]", ui_.ToDict()); + toolbox::SaveText(settings, ini_path_); + } +} + +void App::SetCamera(int idx) { + if (Model()) { + ui_.camera_idx = ::mujoco::toolbox::SetCamera(*Model(), camera_, idx); + } +} + +void App::ClearProfilerData() { + constexpr int kProfilerMaxFrames = 200; + cpu_total_.clear(); + cpu_collision_.clear(); + cpu_prepare_.clear(); + cpu_solve_.clear(); + cpu_other_.clear(); + dim_dof_.clear(); + dim_body_.clear(); + dim_constraint_.clear(); + dim_sqrt_nnz_.clear(); + dim_contact_.clear(); + dim_iteration_.clear(); + + cpu_total_.resize(kProfilerMaxFrames, 0); + cpu_collision_.resize(kProfilerMaxFrames, 0); + cpu_prepare_.resize(kProfilerMaxFrames, 0); + cpu_solve_.resize(kProfilerMaxFrames, 0); + cpu_other_.resize(kProfilerMaxFrames, 0); + dim_dof_.resize(kProfilerMaxFrames, 0); + dim_body_.resize(kProfilerMaxFrames, 0); + dim_constraint_.resize(kProfilerMaxFrames, 0); + dim_sqrt_nnz_.resize(kProfilerMaxFrames, 0); + dim_contact_.resize(kProfilerMaxFrames, 0); + dim_iteration_.resize(kProfilerMaxFrames, 0); +} + +void App::UpdateProfilerData() { + // CPU timers. + mjtNum total = Data()->timer[mjTIMER_STEP].duration; + mjtNum number = static_cast(Data()->timer[mjTIMER_STEP].number); + if (number == 0.0) { + total = Data()->timer[mjTIMER_FORWARD].duration; + number = static_cast(Data()->timer[mjTIMER_FORWARD].number); + } + if (number == 0.0) { + // This can happen if the simulation is paused. + return; + } + + cpu_total_.erase(cpu_total_.begin()); + cpu_total_.push_back(total / number); + + mjtNum collision = Data()->timer[mjTIMER_POS_COLLISION].duration / number; + cpu_collision_.erase(cpu_collision_.begin()); + cpu_collision_.push_back(collision); + + mjtNum prepare = (Data()->timer[mjTIMER_POS_MAKE].duration / number) + + (Data()->timer[mjTIMER_POS_PROJECT].duration / number); + cpu_prepare_.erase(cpu_prepare_.begin()); + cpu_prepare_.push_back(prepare); + + mjtNum solve = Data()->timer[mjTIMER_CONSTRAINT].duration / number; + cpu_solve_.erase(cpu_solve_.begin()); + cpu_solve_.push_back(solve); + + mjtNum other = total - collision - prepare - solve; + cpu_other_.erase(cpu_other_.begin()); + cpu_other_.push_back(other); + + // Dimensions. + mjtNum sqrt_nnz = 0; + int solver_niter = 0; + const int nisland = mjMAX(1, mjMIN(Data()->nisland, mjNISLAND)); + for (int island = 0; island < nisland; island++) { + sqrt_nnz += mju_sqrt(Data()->solver_nnz[island]); + solver_niter += Data()->solver_niter[island]; + } + + dim_dof_.erase(dim_dof_.begin()); + dim_dof_.push_back(Model()->nv); + + dim_body_.erase(dim_body_.begin()); + dim_body_.push_back(Model()->nbody); + + dim_constraint_.erase(dim_constraint_.begin()); + dim_constraint_.push_back(Data()->nefc); + + dim_sqrt_nnz_.erase(dim_sqrt_nnz_.begin()); + dim_sqrt_nnz_.push_back(sqrt_nnz); + + dim_contact_.erase(dim_contact_.begin()); + dim_contact_.push_back(Data()->ncon); + + dim_iteration_.erase(dim_iteration_.begin()); + dim_iteration_.push_back(static_cast(solver_niter) / nisland); +} + +void App::BuildGuiWithWindows() { + if (!tmp_.show_ui_lhs) { + return; + } + + if (ui_.simulation) { + if (ImGui::Begin("Simulation", &ui_.simulation)) { + SimulationGui(); + } + ImGui::End(); + } + if (ui_.physics) { + if (ImGui::Begin("Physics", &ui_.physics)) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.5f); + PhysicsGui(); + ImGui::PopItemWidth(); + } + ImGui::End(); + } + if (ui_.rendering) { + if (ImGui::Begin("Rendering", &ui_.rendering)) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.5f); + RenderingGui(); + ImGui::PopItemWidth(); + } + ImGui::End(); + } + if (ui_.visualization) { + if (ImGui::Begin("Visualization", &ui_.visualization)) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.5f); + VisualizationGui(); + ImGui::PopItemWidth(); + } + ImGui::End(); + } + if (ui_.groups) { + if (ImGui::Begin("Groups", &ui_.groups)) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.5f); + GroupsGui(); + ImGui::PopItemWidth(); + } + ImGui::End(); + } + if (ui_.joints) { + if (ImGui::Begin("Joints", &ui_.joints)) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.5f); + JointsGui(); + ImGui::PopItemWidth(); + } + ImGui::End(); + } + if (ui_.controls) { + if (ImGui::Begin("Controls", &ui_.controls)) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.5f); + ControlsGui(); + ImGui::PopItemWidth(); + } + ImGui::End(); + } + if (ui_.watch) { + if (ImGui::Begin("Watch", &ui_.watch)) { + WatchGui(); + } + ImGui::End(); + } + if (ui_.profiler) { + if (ImGui::Begin("Profiler", &ui_.profiler)) { + ProfilerGui(); + } + ImGui::End(); + } + if (ui_.sensor) { + if (ImGui::Begin("Sensor", &ui_.sensor)) { + SensorGui(); + } + ImGui::End(); + } +} + +void Section(const char* name, ImGuiTreeNodeFlags flags, + std::function content) { + if (ImGui::TreeNodeEx(name, flags)) { + ImGui::Unindent(ImGui::GetTreeNodeToLabelSpacing()); + content(); + ImGui::Indent(ImGui::GetTreeNodeToLabelSpacing()); + ImGui::TreePop(); + } +} + +void App::BuildGuiWithSections() { + // Pixel coordinates of the bottom of the menu bar (the last thing drawn) + float viewport_top = ImGui::GetMainViewport()->WorkPos.y; + float viewport_width = ImGui::GetMainViewport()->WorkSize.x; + + ImGuiWindowFlags window_flags = ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar; + ImGuiTreeNodeFlags section_flags = + ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed; + + const float height = window_->GetHeight(); + auto set_next_window_size_constraint = [height, viewport_top]() { + ImGui::SetNextWindowSizeConstraints( + ImVec2(250, height - viewport_top), + ImVec2(std::numeric_limits::max(), height - viewport_top)); + }; + + if (tmp_.show_ui_lhs) { + set_next_window_size_constraint(); + ImGui::SetNextWindowPos(ImVec2(0, viewport_top), ImGuiCond_Always); + + // Hide the triangular resize grip in the window corner. We do this rather + // then passing ImGuiWindowFlags_NoResize to the window because we still + // want the window to be resizable by dragging on the edge with the mouse. + struct ScopeStyle { + ScopeStyle() { ImGui::PushStyleColor(ImGuiCol_ResizeGrip, 0); } + ~ScopeStyle() { ImGui::PopStyleColor(); } + } style; + + if (ImGui::Begin("LHS_UI", &tmp_.show_ui_lhs, window_flags)) { + ImGuiTreeNodeFlags sim_section_flags = + section_flags | ImGuiTreeNodeFlags_DefaultOpen; + Section("Simulation", sim_section_flags, [this] { SimulationGui(); }); + Section("Physics", section_flags, [this] { PhysicsGui(); }); + Section("Rendering", section_flags, [this] { RenderingGui(); }); + Section("Visualization", section_flags, [this] { VisualizationGui(); }); + Section("Groups", section_flags, [this] { GroupsGui(); }); + Section("Watch", section_flags, [this] { WatchGui(); }); + + // Cache the size and position of the window before we end it + tmp_.size_ui_lhs[0] = ImGui::GetWindowSize().x; + tmp_.size_ui_lhs[1] = ImGui::GetWindowSize().y; + tmp_.pos_ui_lhs[0] = ImGui::GetWindowPos().x; + tmp_.pos_ui_lhs[1] = ImGui::GetWindowPos().y; + } + ImGui::End(); + } else { + tmp_.size_ui_lhs[0] = 0; + tmp_.size_ui_lhs[1] = height - viewport_top; + tmp_.pos_ui_lhs[0] = 0; + tmp_.pos_ui_lhs[1] = viewport_top; + } + + if (tmp_.show_ui_rhs) { + set_next_window_size_constraint(); + ImGui::SetNextWindowPos(ImVec2(viewport_width, viewport_top), + ImGuiCond_Always, ImVec2(1, 0)); + + if (ImGui::Begin("RHS_UI", &tmp_.show_ui_rhs, window_flags)) { + Section("Joints", section_flags, [this] { JointsGui(); }); + Section("Controls", section_flags, [this] { ControlsGui(); }); + Section("Sensor", section_flags, [this] { SensorGui(); }); + Section("Profiler", section_flags, [this] { ProfilerGui(); }); + + // Cache the size and position of the window before we end it + tmp_.size_ui_rhs[0] = ImGui::GetWindowSize().x; + tmp_.size_ui_rhs[1] = ImGui::GetWindowSize().y; + tmp_.pos_ui_rhs[0] = ImGui::GetWindowPos().x; + tmp_.pos_ui_rhs[1] = ImGui::GetWindowPos().y; + } + ImGui::End(); + } else { + tmp_.size_ui_rhs[0] = 0; + tmp_.size_ui_rhs[1] = height - viewport_top; + tmp_.pos_ui_rhs[0] = viewport_width; + tmp_.pos_ui_rhs[1] = viewport_top; + } +} + +void App::BuildGui() { + if (!ui_.classic_ui) { + const int dock_flags = ImGuiDockNodeFlags_PassthruCentralNode | + ImGuiDockNodeFlags_NoDockingOverCentralNode; + ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport(), dock_flags); + } + + MainMenuGui(); + FileDialogGui(); + + if (tmp_.imgui_demo) { + ImGui::ShowDemoWindow(); + ImPlot::ShowDemoWindow(); + } + if (tmp_.style_editor) { + if (ImGui::Begin("Style Editor", &tmp_.style_editor)) { + ImGui::ShowStyleEditor(); + } + ImGui::End(); + } + + if (model_file_.empty()) { + const float width = window_->GetWidth(); + const float height = window_->GetHeight(); + + const int overlay_flags = + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground; + ImGui::SetNextWindowPos(ImVec2(0, height / 2), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(width, height / 2), ImGuiCond_Always); + if (ImGui::Begin("##Overlay", 0, overlay_flags)) { + const char* text = "Load model file or drag-and-drop model file here."; + const auto text_size = ImGui::CalcTextSize(text); + + ImGui::SetCursorPos(ImVec2((width - text_size.x) / 2, 0.0f)); + ImGui::Text("%s", text); + } + ImGui::End(); + } + + if (ui_.classic_ui) { + BuildGuiWithSections(); + } else { + BuildGuiWithWindows(); + } + + struct ScopeOverlayStyle { + ScopeOverlayStyle(bool dark_mode) { + ImColor color = dark_mode ? IM_COL32_BLACK : IM_COL32_WHITE; + color.Value.w = dark_mode ? .5f : .1f; + + ImGui::PushStyleColor(ImGuiCol_WindowBg, (ImU32)color); + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32_WHITE); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0); + } + ~ScopeOverlayStyle() { + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); + } + + const float margin_px = 5; + ImGuiWindowFlags window_flags = + ImGuiWindowFlags_NoTitleBar // No title bar + | ImGuiWindowFlags_NoMove // No window moving + | ImGuiWindowFlags_NoResize // No window resizing + | ImGuiWindowFlags_NoInputs; // User can click through the window + }; + + // Draw the paused overlay + if (physics_->IsPaused()) { + ScopeOverlayStyle style(ui_.dark_mode); + ImVec2 pivot_pos; + pivot_pos.x = ImGui::GetMainViewport()->WorkPos.x + + ImGui::GetMainViewport()->WorkSize.x * 0.5f; + pivot_pos.y = ImGui::GetMainViewport()->WorkPos.y + style.margin_px; + ImVec2 pivot(.5f, .0f); // Top middle + ImGui::SetNextWindowPos(pivot_pos, ImGuiCond_Always, pivot); + if (ImGui::Begin("##PausedOverlay", nullptr, + style.window_flags | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted("Paused"); + } + ImGui::End(); + } + + // Draw the percent real-time simulation speed overlay + { + float desired_realtime = physics_->GetStepControl().GetSpeed(); + float measured_realtime = physics_->GetStepControl().GetSpeedMeasured(); + float realtime_offset = mju_abs(measured_realtime - desired_realtime); + + // If running, check for misalignment of more than 10% + bool misaligned = + !physics_->IsPaused() && realtime_offset > 0.1 * desired_realtime; + + if (desired_realtime != 100.0 || misaligned) { + ScopeOverlayStyle style(ui_.dark_mode); + ImVec2 pivot_pos; + pivot_pos.x = tmp_.pos_ui_lhs[0] + tmp_.size_ui_lhs[0] + style.margin_px; + pivot_pos.y = tmp_.pos_ui_lhs[1] + style.margin_px; + ImGui::SetNextWindowPos(pivot_pos, ImGuiCond_Always); + if (ImGui::Begin( + "##RealTime", nullptr, + style.window_flags | ImGuiWindowFlags_AlwaysAutoResize)) { + if (misaligned) { + ImGui::Text("%g%% (%-4.1f%%)", desired_realtime, measured_realtime); + } else { + ImGui::Text("%g%%", desired_realtime); + } + } + ImGui::End(); + } + } + + // Draw the simulation error overlay + if (!physics_->GetError().empty()) { + ScopeOverlayStyle style(ui_.dark_mode); + ImVec2 pivot_pos; + pivot_pos.x = tmp_.pos_ui_lhs[0] + tmp_.size_ui_lhs[0] + style.margin_px; + pivot_pos.y = ImGui::GetMainViewport()->WorkPos.y + + ImGui::GetMainViewport()->WorkSize.y - style.margin_px; + ImVec2 pivot(0.f, 1.f); // Bottom left + ImGui::SetNextWindowPos(pivot_pos, ImGuiCond_Always, pivot); + if (ImGui::Begin("##SimError", nullptr, + style.window_flags | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("%s", std::string(physics_->GetError()).c_str()); + } + ImGui::End(); + } + + if (tmp_.help) { + ScopeOverlayStyle style(ui_.dark_mode); + ImGui::SetNextWindowPos( + ImVec2(tmp_.pos_ui_lhs[0] + tmp_.size_ui_lhs[0] + style.margin_px, + tmp_.pos_ui_lhs[1] + style.margin_px), + ImGuiCond_Always); + // TODO: Make the width scale with the content/font size + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + if (ImGui::Begin("Help", &tmp_.help, style.window_flags)) { + HelpGui(); + } + ImGui::End(); + } + if (tmp_.info) { + ScopeOverlayStyle style(ui_.dark_mode); + ImGui::SetNextWindowPos( + ImVec2(tmp_.pos_ui_lhs[0] + tmp_.size_ui_lhs[0] + style.margin_px, + tmp_.pos_ui_lhs[1] + tmp_.size_ui_lhs[1] - style.margin_px), + ImGuiCond_Always, ImVec2(0, 1)); + // TODO: Make the width scale with the content/font size + ImGui::SetNextWindowSize(ImVec2(190, 0), ImGuiCond_Always); + if (ImGui::Begin("Info", &tmp_.info, style.window_flags)) { + InfoGui(); + } + ImGui::End(); + } + + ImGuiIO& io = ImGui::GetIO(); + if (io.WantSaveIniSettings) { + SaveSettings(); + io.WantSaveIniSettings = false; + } +} + +void App::MainMenuGui() { + if (ImGui::BeginMainMenuBar()) { + // Add a button to toggle light/dark mode. + // TODO: Move this to the right side of the menu bar. + { + struct ScopeStyle { + ScopeStyle() { + ImColor button_color = ImGui::GetStyle().Colors[ImGuiCol_Button]; + button_color.Value.w = 0.0f; + + ImGui::PushStyleColor(ImGuiCol_Button, (ImU32)button_color); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, (ImU32)button_color); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, (ImU32)button_color); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 2)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0); + } + ~ScopeStyle() { + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(2); + } + } style; + + if (ImGui::Button(ui_.dark_mode ? ICON_DARKMODE : ICON_LIGHTMODE, + ImVec2(ImGui::GetFontSize(), ImGui::GetFontSize()))) { + ui_.dark_mode = !ui_.dark_mode; + if (ui_.dark_mode) { + ImGui::StyleColorsDark(); + } else { + ImGui::StyleColorsLight(); + } + } + } + + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Load Model", "Ctrl+L")) { + ShowPopup(tmp_.load_popup); + } + ImGui::Separator(); + if (ImGui::MenuItem("Save XML", "Ctrl+S")) { + ShowPopup(tmp_.save_xml_popup); + } + if (ImGui::MenuItem("Save MJB", "Ctrl+Shift+S")) { + ShowPopup(tmp_.save_mjb_popup); + } + if (ImGui::MenuItem("Save Screenshot", "Ctrl+P")) { + ShowPopup(tmp_.save_screenshot_popup); + } + ImGui::Separator(); + if (ImGui::MenuItem("Print Model", "Ctrl+M")) { + ShowPopup(tmp_.print_model_popup); + } + if (ImGui::MenuItem("Print Data", "Ctrl+D")) { + ShowPopup(tmp_.print_data_popup); + } + ImGui::Separator(); + if (ImGui::MenuItem("Unload", "Ctrl+U")) { + LoadModel(""); + } + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Ctrl+Q")) { + tmp_.should_exit = true; + } + ImGui::EndMenu(); + } + if (!model_file_.empty()) { + if (ImGui::BeginMenu("Simulation")) { + if (ImGui::MenuItem("Pause", "Space", physics_->IsPaused())) { + physics_->TogglePause(); + } + if (ImGui::MenuItem("Reset", "Backspace")) { + physics_->Reset(); + } + if (ImGui::MenuItem("Reload", "Ctrl+R")) { + LoadModel(model_file_); + } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Save Config")) { + SaveSettings(); + } + if (ImGui::MenuItem("Reset Config")) { + toolbox::SaveText("\n\n", ini_path_); + LoadSettings(); + } + ImGui::Separator(); + + if (ui_.classic_ui) { + if (ImGui::MenuItem( + tmp_.show_ui_lhs ? "Hide Left UI" : "Show Left UI", "Tab")) { + tmp_.show_ui_lhs = !tmp_.show_ui_lhs; + } + if (ImGui::MenuItem( + tmp_.show_ui_rhs ? "Hide Right UI" : "Show Right UI", + "Shift+Tab")) { + tmp_.show_ui_rhs = !tmp_.show_ui_rhs; + } + } else { + if (ImGui::MenuItem(tmp_.show_ui_lhs ? "Hide UI" : "Show UI", + "Tab")) { + tmp_.show_ui_lhs = !tmp_.show_ui_lhs; + tmp_.show_ui_rhs = !tmp_.show_ui_rhs; + } + } + + // Developer option to toggle between dockable window UI and sections UI + if (ImGui::MenuItem("Classic UI", "", ui_.classic_ui)) { + ui_.classic_ui = !ui_.classic_ui; + } + + if (!ui_.classic_ui) { + ImGui::Separator(); + if (ImGui::MenuItem("Simulation", "Alt+S", ui_.simulation)) { + ToggleWindow(ui_.simulation); + } + if (ImGui::MenuItem("Physics", "Alt+P", ui_.physics)) { + ToggleWindow(ui_.physics); + } + if (ImGui::MenuItem("Rendering", "Alt+R", ui_.rendering)) { + ToggleWindow(ui_.rendering); + } + if (ImGui::MenuItem("Visualization", "Alt+V", ui_.visualization)) { + ToggleWindow(ui_.visualization); + } + if (ImGui::MenuItem("Groups", "Alt+G", ui_.groups)) { + ToggleWindow(ui_.groups); + } + if (ImGui::MenuItem("Joints", "Alt+J", ui_.joints)) { + ToggleWindow(ui_.joints); + } + if (ImGui::MenuItem("Controls", "Alt+C", ui_.controls)) { + ToggleWindow(ui_.controls); + } + if (ImGui::MenuItem("Watch", "Alt+W", ui_.watch)) { + ToggleWindow(ui_.watch); + } + if (ImGui::MenuItem("Profiler", "", ui_.profiler)) { + ToggleWindow(ui_.profiler); + ClearProfilerData(); + } + if (ImGui::MenuItem("Sensor", "", ui_.sensor)) { + ToggleWindow(ui_.sensor); + } + } + ImGui::EndMenu(); + } + } + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Help", "F1", tmp_.help)) { + ToggleWindow(tmp_.help); + } + if (ImGui::MenuItem("Info", "F2", tmp_.info)) { + ToggleWindow(tmp_.info); + } + ImGui::Separator(); + if (ImGui::MenuItem("Style Editor", "", tmp_.style_editor)) { + tmp_.style_editor = !tmp_.style_editor; + } + ImGui::Separator(); + if (ImGui::MenuItem("ImGui Demo")) { + tmp_.imgui_demo = !tmp_.imgui_demo; + } + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); + } +} + +void App::FileDialogGui() { + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + + if (tmp_.load_popup) { + ImGui::OpenPopup("LoadModel"); + tmp_.load_popup = false; + tmp_.filename[0] = 0; + } + if (tmp_.save_xml_popup) { + ImGui::OpenPopup("SaveXML"); + tmp_.save_xml_popup = false; + tmp_.filename[0] = 0; + } + if (tmp_.save_mjb_popup) { + ImGui::OpenPopup("SaveMJB"); + tmp_.save_mjb_popup = false; + tmp_.filename[0] = 0; + } + if (tmp_.save_screenshot_popup) { + ImGui::OpenPopup("SaveWebp"); + tmp_.save_screenshot_popup = false; + tmp_.filename[0] = 0; + } + if (tmp_.print_model_popup) { + ImGui::OpenPopup("PrintModel"); + tmp_.print_model_popup = false; + tmp_.filename[0] = 0; + } + if (tmp_.print_data_popup) { + ImGui::OpenPopup("PrintData"); + tmp_.print_data_popup = false; + tmp_.filename[0] = 0; + } + + tmp_.modal_open = + ImGui::IsPopupOpen("SaveXML") || ImGui::IsPopupOpen("SaveMJB") || + ImGui::IsPopupOpen("SaveWebp") || ImGui::IsPopupOpen("PrintModel") || + ImGui::IsPopupOpen("PrintData"); + + if (ImGui::BeginPopupModal("LoadModel", NULL, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui_FileDialog(tmp_.filename, sizeof(tmp_.filename))) { + LoadModel(tmp_.filename); + } + ImGui::EndPopup(); + } + if (ImGui::BeginPopupModal("SaveXML", NULL, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui_FileDialog(tmp_.filename, sizeof(tmp_.filename))) { + char err[1000] = ""; + mj_saveLastXML(tmp_.filename, Model(), err, 1000); + } + ImGui::EndPopup(); + } + if (ImGui::BeginPopupModal("SaveMJB", NULL, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui_FileDialog(tmp_.filename, sizeof(tmp_.filename))) { + mj_saveModel(Model(), tmp_.filename, nullptr, 0); + } + ImGui::EndPopup(); + } + if (ImGui::BeginPopupModal("SaveWebp", NULL, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui_FileDialog(tmp_.filename, sizeof(tmp_.filename))) { + renderer_->SaveScreenshot(tmp_.filename, window_->GetWidth(), + window_->GetHeight()); + } + ImGui::EndPopup(); + } + if (ImGui::BeginPopupModal("PrintModel", NULL, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui_FileDialog(tmp_.filename, sizeof(tmp_.filename))) { + mj_printModel(Model(), tmp_.filename); + } + ImGui::EndPopup(); + } + if (ImGui::BeginPopupModal("PrintData", NULL, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui_FileDialog(tmp_.filename, sizeof(tmp_.filename))) { + mj_printData(Model(), Data(), tmp_.filename); + } + ImGui::EndPopup(); + } +} + +struct ScopeGreyText { + ScopeGreyText(bool dark_mode) { + const float v = dark_mode ? .6f : .9f; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(v, v, v, 1.f)); + } + ~ScopeGreyText() { ImGui::PopStyleColor(); } +}; + +void App::HelpGui() { + ImGui::Columns(4); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.35f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.15f); + ImGui::SetColumnWidth(2, ImGui::GetWindowWidth() * 0.4f); + ImGui::SetColumnWidth(3, ImGui::GetWindowWidth() * 0.1f); + + { + ScopeGreyText style(ui_.dark_mode); + ImGui::Text("Help"); + ImGui::Text("Info"); + ImGui::Text("Cycle Frames"); + ImGui::Text("Cycle Labels"); + ImGui::Text("Free Camera"); + ImGui::Text("Toggle Pause"); + ImGui::Text("Reset Sim"); + ImGui::Text("Show/Hide UI"); + ImGui::Text("Speed Up"); + ImGui::Text("Speed Down"); + ImGui::Text("Prev Camera"); + ImGui::Text("Next Camera"); + ImGui::Text("Step Back"); + ImGui::Text("Step Forward"); + ImGui::Text("Select Parent"); + ImGui::Text("Align Camera"); + ImGui::Text("Copy Keyframe"); + ImGui::Text("Geom Group"); + ImGui::Text("Site Group"); + } + + ImGui::NextColumn(); + ImGui::Text("F1"); + ImGui::Text("F2"); + ImGui::Text("F6"); + ImGui::Text("F7"); + ImGui::Text("Esc"); + ImGui::Text("Spc"); + ImGui::Text("Bksp"); + ImGui::Text("Tab"); + ImGui::Text("="); + ImGui::Text("-"); + ImGui::Text("["); + ImGui::Text("]"); + ImGui::Text("Left"); + ImGui::Text("Right"); + ImGui::Text("PgUp"); + ImGui::Text("Ctrl+A"); + ImGui::Text("Ctrl+C"); + ImGui::Text("0-5"); + ImGui::Text("Sh+0-5"); + + ImGui::NextColumn(); + { + ScopeGreyText style(ui_.dark_mode); + ImGui::Text("Activation"); + ImGui::Text("Auto Connect"); + ImGui::Text("Body Tree"); + ImGui::Text("Mesh Tree"); + ImGui::Text("Scale Inertia"); + ImGui::Text("Skin"); + ImGui::Text("Actuator"); + ImGui::Text("Camera"); + ImGui::Text("Center of Mass"); + ImGui::Text("Contact Force"); + ImGui::Text("Contact Point"); + ImGui::Text("Contact Split"); + ImGui::Text("Convex Hull"); + ImGui::Text("Equality"); + ImGui::Text("Island"); + ImGui::Text("Joint"); + ImGui::Text("Light"); + ImGui::Text("Perturb Force"); + ImGui::Text("Perturb Object"); + ImGui::Text("Range Finder"); + ImGui::Text("Static Body"); + ImGui::Text("Tendon"); + ImGui::Text("Texture"); + ImGui::Text("Transparent"); + } + + ImGui::NextColumn(); + ImGui::Text(","); + ImGui::Text("K"); + ImGui::Text("`"); + ImGui::Text("\\"); + ImGui::Text("\""); + ImGui::Text(";"); + ImGui::Text("U"); + ImGui::Text("Q"); + ImGui::Text("M"); + ImGui::Text("F"); + ImGui::Text("C"); + ImGui::Text("P"); + ImGui::Text("H"); + ImGui::Text("E"); + ImGui::Text("I"); + ImGui::Text("J"); + ImGui::Text("Z"); + ImGui::Text("B"); + ImGui::Text("O"); + ImGui::Text("Y"); + ImGui::Text("G"); + ImGui::Text("V"); + ImGui::Text("X"); + ImGui::Text("T"); + + ImGui::Columns(); +} + +void App::SimulationGui() { + float margin = ImGui::GetStyle().ItemSpacing.x; + float indent = ImGui::GetStyle().IndentSpacing; + float window_width = ImGui::GetWindowWidth(); + float play_offset_x = (ui_.classic_ui ? 1.f : 2.f) * margin; + if (ImGui::Button(physics_->IsPaused() ? ICON_PLAY : ICON_PAUSE, + ImVec2(window_width - play_offset_x, 40))) { + physics_->TogglePause(); + } + + if (ImGui::TreeNodeEx("History")) { + ImGui::PushItemWidth(window_width - 2 * margin - 2 * indent); + const int max_history = + std::min(physics_->GetStepCount(), physics_->GetHistorySize()); + if (ImGui::SliderInt("##ScrubIndex", &ui_.scrub_idx, -max_history, 0)) { + physics_->LoadHistory(ui_.scrub_idx); + } + ImGui::PopItemWidth(); + ImGui::TreePop(); + } + + if (ImGui::TreeNodeEx("Keyframes")) { + ImGui::SliderInt("Key", &ui_.key_idx, 0, Model()->nkey); + if (ImGui::Button("Load")) { + mj_resetDataKeyframe(Model(), Data(), ui_.key_idx); + mj_forward(Model(), Data()); + } + ImGui::SameLine(); + if (ImGui::Button("Save")) { + mj_setKeyframe(Model(), Data(), ui_.key_idx); + } + if (ImGui::Button("Copy to Clipboard")) { + std::string keyframe = toolbox::KeyframeToString(Model(), Data(), false); + toolbox::MaybeSaveToClipboard(keyframe); + } + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Noise")) { + float noise_scale, noise_rate; + physics_->GetStepControl().GetNoiseParameters(noise_scale, noise_rate); + ImGui::SliderFloat("Scale", &noise_scale, 0, 1); + ImGui::SliderFloat("Rate", &noise_rate, 0, 4); + physics_->GetStepControl().SetNoiseParameters(noise_scale, noise_rate); + ImGui::TreePop(); + } +} + +void App::SensorGui() {} + +void App::ProfilerGui() { + const int plot_flags = 0; + + if (ImPlot::BeginPlot("CPU Time", ImVec2(-1, 0), plot_flags)) { + ImPlot::SetupAxis(ImAxis_X1, "frame", ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxis(ImAxis_Y1, "msec", ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxisFormat(ImAxis_Y1, "%.2f"); + ImPlot::SetupLegend(ImPlotLocation_NorthEast); + ImPlot::SetupFinish(); + + ImPlot::PlotLine("total", cpu_total_.data(), cpu_total_.size(), 1, + -(int)cpu_total_.size()); + ImPlot::PlotLine("prepare", cpu_prepare_.data(), cpu_prepare_.size(), 1, + -(int)cpu_prepare_.size()); + ImPlot::PlotLine("solve", cpu_solve_.data(), cpu_solve_.size(), 1, + -(int)cpu_solve_.size()); + ImPlot::PlotLine("collision", cpu_collision_.data(), cpu_collision_.size(), + 1, -(int)cpu_collision_.size()); + ImPlot::PlotLine("other", cpu_other_.data(), cpu_other_.size(), 1, + -(int)cpu_other_.size()); + ImPlot::EndPlot(); + } + + if (ImPlot::BeginPlot("Dimensions", ImVec2(-1, 0), plot_flags)) { + ImPlot::SetupAxis(ImAxis_X1, "frame", ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxis(ImAxis_Y1, "count", ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxisFormat(ImAxis_Y1, "%.0f"); + ImPlot::SetupLegend(ImPlotLocation_NorthEast); + ImPlot::SetupFinish(); + + ImPlot::PlotLine("dof", dim_dof_.data(), dim_dof_.size(), 1, + -(int)dim_dof_.size()); + ImPlot::PlotLine("body", dim_body_.data(), dim_body_.size(), 1, + -(int)dim_body_.size()); + ImPlot::PlotLine("constraint", dim_constraint_.data(), + dim_constraint_.size(), 1, -(int)dim_constraint_.size()); + ImPlot::PlotLine("sqrt(nnz)", dim_sqrt_nnz_.data(), dim_sqrt_nnz_.size(), 1, + -(int)dim_sqrt_nnz_.size()); + ImPlot::PlotLine("contact", dim_contact_.data(), dim_contact_.size(), 1, + -(int)dim_contact_.size()); + ImPlot::PlotLine("iteration", dim_iteration_.data(), dim_iteration_.size(), + 1, -(int)dim_iteration_.size()); + ImPlot::EndPlot(); + } +} + +void App::InfoGui() { + const int num_islands = std::clamp(Data()->nisland, 1, mjNISLAND); + + // compute solver error (maximum over islands) + mjtNum solver_err = 0; + int solver_iter = 0; + for (int i = 0; i < num_islands; i++) { + solver_iter += Data()->solver_niter[i]; + + mjtNum solerr_i = 0; + if (Data()->solver_niter[i]) { + const int ind = mjMIN(Data()->solver_niter[i], mjNSOLVER) - 1; + const mjSolverStat* stat = Data()->solver + i * mjNSOLVER + ind; + solerr_i = mju_min(stat->improvement, stat->gradient); + if (solerr_i == 0) { + solerr_i = mju_max(stat->improvement, stat->gradient); + } + } + solver_err = mju_max(solver_err, solerr_i); + } + solver_err = mju_log10(mju_max(mjMINVAL, solver_err)); + + auto type = physics_->IsPaused() ? mjTIMER_FORWARD : mjTIMER_STEP; + auto cpu = + Data()->timer[type].duration / mjMAX(1, Data()->timer[type].number); + auto mempct = 100 * Data()->maxuse_arena / (double)(Data()->narena); + auto memlimit = mju_writeNumBytes(Data()->narena); + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.4f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.6f); + + { + ScopeGreyText style(ui_.dark_mode); + ImGui::Text("Time"); + ImGui::Text("Size"); + ImGui::Text("CPU"); + ImGui::Text("Solver"); + ImGui::Text("FPS"); + ImGui::Text("Memory"); + if (Model()->opt.enableflags & mjENBL_ENERGY) { + ImGui::Text("Energy"); + } + if (Model()->opt.enableflags & mjENBL_FWDINV) { + ImGui::Text("FwdInv"); + } + if (!(Model()->opt.disableflags & mjDSBL_ISLAND)) { + ImGui::Text("Islands"); + } + } + + ImGui::NextColumn(); + ImGui::Text("%-9.3f", Data()->time); + ImGui::Text("%d (%d con)", Data()->nefc, Data()->ncon); + ImGui::Text("%.3f", cpu); + ImGui::Text("%.1f (%d it)", solver_err, solver_iter); + ImGui::Text("%0.1f", renderer_->GetFrameRate()); + ImGui::Text("%.1f%% of %s", mempct, memlimit); + if (Model()->opt.enableflags & mjENBL_ENERGY) { + ImGui::Text("%.3f", Data()->energy[0] + Data()->energy[1]); + } + if (Model()->opt.enableflags & mjENBL_FWDINV) { + ImGui::Text("%.1f %.1f", + mju_log10(mju_max(mjMINVAL, Data()->solver_fwdinv[0])), + mju_log10(mju_max(mjMINVAL, Data()->solver_fwdinv[1]))); + } + if (!(Model()->opt.disableflags & mjDSBL_ISLAND)) { + ImGui::Text("%d", Data()->nisland); + } + ImGui::Columns(); +} + +void App::WatchGui() { + ImGui::InputText("Field", ui_.watch_field, sizeof(ui_.watch_field)); + ImGui::InputInt("Index", &ui_.watch_index); + const mjtNum* value = static_cast( + toolbox::GetValue(Model(), Data(), ui_.watch_field, ui_.watch_index)); + if (value) { + ImGui::Text("%0.3f", *(const mjtNum*)value); + } else { + ImGui::Text("Invalid field/index!"); + } +} + +void App::PhysicsGui() { + auto& opt = Model()->opt; + + const char* opts0[] = {"Euler", "RK4", "implicit", "implicitfast"}; + ImGui::Combo("Integrator", &opt.integrator, opts0, IM_ARRAYSIZE(opts0)); + + const char* opts1[] = {"Pyramidal", "Elliptic"}; + ImGui::Combo("Cone", &opt.cone, opts1, IM_ARRAYSIZE(opts1)); + + const char* opts2[] = {"Dense", "Sparse", "Auto"}; + ImGui::Combo("Jacobian", &opt.jacobian, opts2, IM_ARRAYSIZE(opts2)); + + const char* opts3[] = {"PGS", "CG", "Newton"}; + ImGui::Combo("Solver", &opt.solver, opts3, IM_ARRAYSIZE(opts3)); + + if (ImGui::TreeNodeEx("Algorithmic Parameters", + ImGuiTreeNodeFlags_DefaultOpen)) { + float w = ImGui::GetWindowWidth() * .6f; + ImGui_Input("Timestep", &opt.timestep, {0, 1, 0.01, 0.1, w}); + ImGui_Input("Iterations", &opt.iterations, {0, 1000, 1, 10, w}); + ImGui_Input("Tolerance", &opt.tolerance, {0, 1, 1e-7, 1e-6, w}); + ImGui_Input("LS Iter", &opt.ls_iterations, {0, 100, 1, 0.1, w}); + ImGui_Input("LS Tol", &opt.ls_tolerance, {0, 0.1, 0.01, 0.1, w}); + ImGui_Input("Noslip Iter", &opt.noslip_iterations, {0, 1000, 1, 100, w}); + ImGui_Input("Noslip Tol", &opt.noslip_tolerance, {0, 1, 0.01, 0.1, w}); + ImGui_Input("CCD Iter", &opt.ccd_iterations, {0, 1000, 1, 100, w}); + ImGui_Input("CCD Tol", &opt.ccd_tolerance, {0, 1, 0.01, 0.1, w}); + ImGui_Input("API Rate", &opt.apirate, {0, 1000, 1, 100, w}); + ImGui_Input("SDF Iter", &opt.sdf_iterations, {1, 20, 1, 10, w}); + ImGui_Input("SDF Init", &opt.sdf_initpoints, {1, 100, 1, 10, w}); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Physical Parameters")) { + float w = ImGui::GetWindowWidth() * .6f; + ImGui_InputN("Gravity", opt.gravity, 3, {.width = w}); + ImGui_InputN("Wind", opt.wind, 3, {.width = w}); + ImGui_InputN("Magnetic", opt.magnetic, 3, {.width = w}); + ImGui_Input("Density", &opt.density, {.min = .1, .max = 1, .width = w}); + ImGui_Input("Viscosity", &opt.viscosity, {.min = .1, .max = 1, .width = w}); + ImGui_Input("Imp Ratio", &opt.impratio, {.min = .1, .max = 1, .width = w}); + ImGui::TreePop(); + }; + if (ImGui::TreeNodeEx("Contact Override")) { + float w = ImGui::GetWindowWidth() * .6f; + ImGui_Input("Margin", &opt.o_margin, {.min = 0.1, .max = 1, .width = w}); + ImGui_InputN("Sol Imp", opt.o_solimp, 5, {.width = w, .format = "%0.1f"}); + ImGui_InputN("Sol Ref", opt.o_solref, 2, {.width = w, .format = "%0.1f"}); + ImGui_InputN("Friction", opt.o_friction, 5, {.width = w, .format = "%.1f"}); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Disable Flags", ImGuiTreeNodeFlags_DefaultOpen)) { + for (int i = 0; i < mjNDISABLE; ++i) { + ToggleBit(mjDISABLESTRING[i], opt.disableflags, 1 << i); + if (i % 2 == 0 && i != mjNDISABLE - 1) { + ImGui::SameLine(); + } + } + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Enable Flags", ImGuiTreeNodeFlags_DefaultOpen)) { + for (int i = 0; i < mjNENABLE; ++i) { + ToggleBit(mjENABLESTRING[i], opt.enableflags, 1 << i); + if (i % 2 == 0 && i != mjNENABLE - 1) { + ImGui::SameLine(); + } + } + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Actuator Group Disable")) { + ToggleBit("Act Group 0", opt.disableactuator, 1 << 0); + ImGui::SameLine(); + ToggleBit("Act Group 1", opt.disableactuator, 1 << 1); + ToggleBit("Act Group 2", opt.disableactuator, 1 << 2); + ImGui::SameLine(); + ToggleBit("Act Group 3", opt.disableactuator, 1 << 3); + ToggleBit("Act Group 4", opt.disableactuator, 1 << 4); + ImGui::SameLine(); + ToggleBit("Act Group 5", opt.disableactuator, 1 << 5); + ImGui::TreePop(); + }; +} + +void App::VisualizationGui() { + auto& vis = Model()->vis; + auto& stat = Model()->stat; + if (ImGui::TreeNodeEx("Headlight")) { + Toggle("Active", vis.headlight.active, ToggleKind::kSlider); + // TODO: These are not working + ImGui::ColorEdit3("Ambient", vis.headlight.ambient); + ImGui::ColorEdit3("Diffuse", vis.headlight.diffuse); + ImGui::ColorEdit3("Specular", vis.headlight.specular); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Free Camera")) { + Toggle("Orthographic", vis.global.orthographic, ToggleKind::kSlider); + ImGui_Input("FOV", &vis.global.fovy, {.format = "%0.2f"}); + ImGui_InputN("Center", stat.center, 3, {.format = "%0.2f"}); + ImGui_Input("Azimuth", &vis.global.azimuth, {.format = "%0.2f"}); + ImGui_Input("Elevation", &vis.global.elevation, {.format = "%0.2f"}); + if (ImGui::Button("Align")) { + mjv_defaultFreeCamera(Model(), &camera_); + } + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Global")) { + ImGui_Input("Extent", &stat.extent); + const char* opts[] = {"Box", "Ellipsoid"}; + ImGui::SliderInt("Inertia", &vis.global.ellipsoidinertia, 0, 1, + opts[vis.global.ellipsoidinertia]); + Toggle("BVH active", vis.global.bvactive); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Map")) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.3f); + ImGui_Input("Stiffness", &vis.map.stiffness); + ImGui_Input("Rot stiffness", &vis.map.stiffnessrot); + ImGui_Input("Force", &vis.map.force); + ImGui_Input("Torque", &vis.map.torque); + ImGui_Input("Alpha", &vis.map.alpha); + ImGui_Input("Fog start", &vis.map.fogstart); + ImGui_Input("Fog end", &vis.map.fogend); + ImGui_Input("Z near", &vis.map.znear); + ImGui_Input("Z far", &vis.map.zfar); + ImGui_Input("Haze", &vis.map.haze); + ImGui_Input("Shadow clip", &vis.map.shadowclip); + ImGui_Input("Shadow scale", &vis.map.shadowscale); + ImGui::PopItemWidth(); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Scale")) { + ImGui::PushItemWidth(ImGui::GetWindowWidth() * 0.3f); + ImGui_Input("All (meansize)", &stat.meansize, {.format = "%0.3f"}); + ImGui_Input("Force width", &vis.scale.forcewidth); + ImGui_Input("Contact width", &vis.scale.contactwidth); + ImGui_Input("Contact height", &vis.scale.contactheight); + ImGui_Input("Connect", &vis.scale.connect); + ImGui_Input("Com", &vis.scale.com); + ImGui_Input("Camera", &vis.scale.camera); + ImGui_Input("Light", &vis.scale.light); + ImGui_Input("Select point", &vis.scale.selectpoint); + ImGui_Input("Joint length", &vis.scale.jointlength); + ImGui_Input("Joint width", &vis.scale.jointwidth); + ImGui_Input("Actuator length", &vis.scale.actuatorlength); + ImGui_Input("Actuator width", &vis.scale.actuatorwidth); + ImGui_Input("Frame length", &vis.scale.framelength); + ImGui_Input("Frame width", &vis.scale.framewidth); + ImGui_Input("Constraint", &vis.scale.constraint); + ImGui_Input("Slider-crank", &vis.scale.slidercrank); + ImGui::PopItemWidth(); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx("Colors")) { + ImGui::ColorEdit4("fog", vis.rgba.fog); + ImGui::ColorEdit4("haze", vis.rgba.haze); + ImGui::ColorEdit4("force", vis.rgba.force); + ImGui::ColorEdit4("inertia", vis.rgba.inertia); + ImGui::ColorEdit4("joint", vis.rgba.joint); + ImGui::ColorEdit4("actuator", vis.rgba.actuator); + ImGui::ColorEdit4("actnegative", vis.rgba.actuatornegative); + ImGui::ColorEdit4("actpositive", vis.rgba.actuatorpositive); + ImGui::ColorEdit4("com", vis.rgba.com); + ImGui::ColorEdit4("camera", vis.rgba.camera); + ImGui::ColorEdit4("light", vis.rgba.light); + ImGui::ColorEdit4("selectpoint", vis.rgba.selectpoint); + ImGui::ColorEdit4("connect", vis.rgba.connect); + ImGui::ColorEdit4("contactpoint", vis.rgba.contactpoint); + ImGui::ColorEdit4("contactforce", vis.rgba.contactforce); + ImGui::ColorEdit4("contactfriction", vis.rgba.contactfriction); + ImGui::ColorEdit4("contacttorque", vis.rgba.contacttorque); + ImGui::ColorEdit4("contactgap", vis.rgba.contactgap); + ImGui::ColorEdit4("rangefinder", vis.rgba.rangefinder); + ImGui::ColorEdit4("constraint", vis.rgba.constraint); + ImGui::ColorEdit4("slidercrank", vis.rgba.slidercrank); + ImGui::ColorEdit4("crankbroken", vis.rgba.crankbroken); + ImGui::ColorEdit4("frustum", vis.rgba.frustum); + ImGui::ColorEdit4("bv", vis.rgba.bv); + ImGui::ColorEdit4("bvactive", vis.rgba.bvactive); + ImGui::TreePop(); + } +} + +void App::RenderingGui() { + // Generate a list of camera names dynamically. + std::vector camera_names; + camera_names.push_back("Free"); + camera_names.push_back("Tracking"); + for (int i = 0; i < Model()->ncam; i++) { + if (Model()->names[Model()->name_camadr[i]]) { + camera_names.push_back(Model()->names + Model()->name_camadr[i]); + } else { + camera_names.push_back("(Unnamed)"); + } + } + + ImGui::Combo("Label", &vis_options_.label, kLabelNames, + IM_ARRAYSIZE(kLabelNames)); + ImGui::Combo("Frame", &vis_options_.frame, kFrameNames, + IM_ARRAYSIZE(kFrameNames)); + if (ImGui::Combo("Camera", &ui_.camera_idx, camera_names.data(), + camera_names.size())) { + SetCamera(ui_.camera_idx); + } + if (ImGui::Button("Copy Camera")) { + std::string camera_string = toolbox::CameraToString(&renderer_->GetScene()); + toolbox::MaybeSaveToClipboard(camera_string); + } + + ImGui::SliderInt("Tree depth", &vis_options_.bvh_depth, 0, 20); + ImGui::SliderInt("Flex layer", &vis_options_.flex_layer, 0, 10); + + if (ImGui::TreeNodeEx("Model Elements", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Unindent(ImGui::GetTreeNodeToLabelSpacing() / 2); + + for (int i = 0; i < mjNVISFLAG; ++i) { + Toggle(mjVISSTRING[i][0], vis_options_.flags[i]); + if (i % 2 == 0 && i != mjNVISFLAG - 1) { + ImGui::SameLine(); + } + } + + ImGui::Indent(ImGui::GetTreeNodeToLabelSpacing() / 2); + ImGui::TreePop(); + } +} + +void App::GroupsGui() { + auto GroupGui = [](const char* name, mjtByte* group) { + if (ImGui::TreeNodeEx(name, ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Unindent(ImGui::GetTreeNodeToLabelSpacing() / 2); + + for (int i = 0; i < 6; ++i) { + char label[64]; + std::snprintf(label, sizeof(label), "%s %d", name, i); + + Toggle(label, group[i]); + + if (i % 2 == 0) ImGui::SameLine(); + } + + ImGui::Indent(ImGui::GetTreeNodeToLabelSpacing() / 2); + ImGui::TreePop(); + } + }; + + GroupGui("Geoms", vis_options_.geomgroup); + GroupGui("Sites", vis_options_.sitegroup); + GroupGui("Joints", vis_options_.jointgroup); + GroupGui("Tendons", vis_options_.tendongroup); + GroupGui("Actuators", vis_options_.actuatorgroup); + GroupGui("Flexes", vis_options_.flexgroup); + GroupGui("Skins", vis_options_.skingroup); +} + +void App::JointsGui() { + char name[100]; + for (int i = 0; i < Model()->njnt; ++i) { + if (Model()->jnt_type[i] != mjJNT_HINGE && + Model()->jnt_type[i] != mjJNT_SLIDE) { + continue; + } + const int group = std::clamp(Model()->jnt_group[i], 0, mjNGROUP - 1); + if (!vis_options_.jointgroup[group]) { + continue; + } + + const char* jnt_name = Model()->names + Model()->name_jntadr[i]; + if (*jnt_name) { + std::snprintf(name, sizeof(name), "%s", jnt_name); + } else { + std::snprintf(name, sizeof(name), "joint %d", i); + } + + double min = -1.0; + double max = 1.0; + if (Model()->jnt_limited[i]) { + min = Model()->jnt_range[2 * i + 0]; + max = Model()->jnt_range[2 * i + 1]; + } else if (Model()->jnt_type[i] == mjJNT_SLIDE) { + min = -1.0; + max = 1.0; + } else { + min = -3.1416; + max = 3.1416; + } + + const int data_adr = Model()->jnt_qposadr[i]; + ImGui_Slider(name, &Data()->qpos[data_adr], min, max); + } +} + +void App::ControlsGui() { + if (ImGui::Button("Clear All")) { + mju_zero(Data()->ctrl, Model()->nu); + } + + char name[100]; + for (int i = 0; i < Model()->nu; i++) { + int group = std::clamp(Model()->actuator_group[i], 0, mjNGROUP - 1); + if (!vis_options_.actuatorgroup[group]) { + continue; + } + if (group >= 0 && group <= 30 && + Model()->opt.disableactuator & (1 << group)) { + continue; + } + + const char* ctrl_name = Model()->names + Model()->name_actuatoradr[i]; + if (*ctrl_name) { + std::snprintf(name, sizeof(name), "%s", ctrl_name); + } else { + std::snprintf(name, sizeof(name), "control %d", i); + } + + double min = -1.0; + double max = 1.0; + if (!Model()->actuator_ctrllimited[i]) { + min = Model()->actuator_ctrlrange[2 * i + 0]; + max = Model()->actuator_ctrlrange[2 * i + 1]; + } + ImGui_Slider(name, &Data()->ctrl[i], min, max); + } +} + +App::UiState::Dict App::UiState::ToDict() const { + return { + {"Simulation", simulation ? "1" : "0"}, + {"Physics", physics ? "1" : "0"}, + {"Rendering", rendering ? "1" : "0"}, + {"Watch", watch ? "1" : "0"}, + {"Visualization", visualization ? "1" : "0"}, + {"Groups", groups ? "1" : "0"}, + {"Joints", joints ? "1" : "0"}, + {"Controls", controls ? "1" : "0"}, + {"Profiler", profiler ? "1" : "0"}, + {"Sensor", sensor ? "1" : "0"}, + }; +} + +void App::UiState::FromDict(const Dict& dict) { + auto read_bool = [&](const char* key, bool& value) { + auto iter = dict.find(key); + if (iter != dict.end()) { + value = iter->second == "1"; + } + }; + + *this = UiState(); + + read_bool("Simulation", simulation); + read_bool("Physics", physics); + read_bool("Rendering", rendering); + read_bool("Watch", watch); + read_bool("Visualization", visualization); + read_bool("Groups", groups); + read_bool("Joints", joints); + read_bool("Controls", controls); + read_bool("Profiler", profiler); + read_bool("Sensor", sensor); +} + +int App::LoadAssetCallback(const char* path, void* user_data, + unsigned char** out, std::uint64_t* out_size) { + App* app = static_cast(user_data); + std::vector bytes = (app->load_asset_fn_)(path); + if (bytes.empty()) { + *out_size = 0; + return 0; // Empty file + } + + *out_size = bytes.size(); + *out = reinterpret_cast(malloc(*out_size)); + if (*out == nullptr) { + mju_error("Failed to allocate memory for file %s", path); + return -1; + } + + std::memcpy(*out, bytes.data(), *out_size); + return 0; +} + +} // namespace mujoco::studio diff --git a/src/experimental/studio/app.h b/src/experimental/studio/app.h new file mode 100644 index 00000000..be0bfefe --- /dev/null +++ b/src/experimental/studio/app.h @@ -0,0 +1,194 @@ +// Copyright 2025 DeepMind Technologies Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MUJOCO_SRC_EXPERIMENTAL_STUDIO_APP_H_ +#define MUJOCO_SRC_EXPERIMENTAL_STUDIO_APP_H_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "experimental/toolbox/helpers.h" +#include "experimental/toolbox/physics.h" +#include "experimental/toolbox/renderer.h" +#include "experimental/toolbox/window.h" + +namespace mujoco::studio { + +// Owns, updates, and renders a MuJoCo simulation. +class App { + public: + using Clock = std::chrono::steady_clock; + using TimePoint = std::chrono::time_point; + using Seconds = std::chrono::duration; + using Milliseconds = std::chrono::duration; + + App(int width, int height, std::string ini_path, + const toolbox::LoadAssetFn& load_asset_fn); + + // Loads a model into the simulation. + void LoadModel(std::string model_file); + + // Processes window events and advances the state of the simulation. + bool Update(); + + // Syncs the state of the simulation with the renderer. + void Sync(); + + // Builds the GUI. We do this after Sync() to ensure we have the latest data + // for building the GUI. + void BuildGui(); + + // Renders everything (e.g. the simulation and the GUI). + void Render(); + + private: + static int LoadAssetCallback(const char* path, void* user_data, + unsigned char** out, std::uint64_t* out_size); + + // UI state that is persisted across application runs + struct UiState { + bool classic_ui = true; + + char watch_field[1000] = "qpos"; + int watch_index = 0; + int camera_idx = 0; + int key_idx = 0; + int scrub_idx = 0; + bool dark_mode = true; + + // UI visibility. + bool simulation = false; + bool physics = false; + bool rendering = false; + bool watch = false; + bool visualization = false; + bool groups = false; + bool joints = false; + bool controls = false; + bool profiler = false; + bool sensor = false; + + using Dict = std::unordered_map; + Dict ToDict() const; + void FromDict(const Dict& dict); + }; + + // UI state that is transient and only needed while the application runs + // TODO(matijak): Combine this with UiState and identify the list of transient + // variables with a comment + struct UiTempState { + // bool paused = false; // Application starts with the simulation running. + bool help = false; + bool info = false; + bool modal_open = false; + bool load_popup = false; + bool save_xml_popup = false; + bool save_mjb_popup = false; + bool save_screenshot_popup = false; + bool print_model_popup = false; + bool print_data_popup = false; + bool should_exit = false; + // bool should_reload = false; + bool style_editor = false; + bool imgui_demo = false; + bool perturb_active = false; + + int speed_index = 0; + + // Visibility, position and size of the left and right UI panels + bool show_ui_lhs = true; + bool show_ui_rhs = true; + float pos_ui_lhs[2]; + float pos_ui_rhs[2]; + float size_ui_lhs[2]; + float size_ui_rhs[2]; + + char filename[1000] = ""; + }; + + void OnModelLoaded(std::string_view model_file); + + void LoadSettings(); + void SaveSettings(); + + void SetCamera(int idx); + + void ChangeSpeed(int delta); + + void HandleMouseEvents(); + void HandleKeyboardEvents(); + + void ClearProfilerData(); + void UpdateProfilerData(); + + void BuildGuiWithWindows(); + void BuildGuiWithSections(); + + void InfoGui(); + void HelpGui(); + void MainMenuGui(); + void FileDialogGui(); + void SimulationGui(); + void PhysicsGui(); + void RenderingGui(); + void VisualizationGui(); + void GroupsGui(); + void WatchGui(); + void SensorGui(); + void ProfilerGui(); + void JointsGui(); + void ControlsGui(); + + mjModel* Model() { return physics_->GetModel(); }; + mjData* Data() { return physics_->GetData(); }; + + std::string ini_path_; + std::string model_file_; + + std::unique_ptr window_; + std::unique_ptr renderer_; + std::unique_ptr physics_; + toolbox::LoadAssetFn load_asset_fn_; + + mjvCamera camera_; + mjvPerturb perturb_; + mjvOption vis_options_; + + // profiler data + std::vector cpu_total_; + std::vector cpu_collision_; + std::vector cpu_prepare_; + std::vector cpu_solve_; + std::vector cpu_other_; + std::vector dim_dof_; + std::vector dim_body_; + std::vector dim_constraint_; + std::vector dim_sqrt_nnz_; + std::vector dim_contact_; + std::vector dim_iteration_; + + UiState ui_; + UiTempState tmp_; +}; + +} // namespace mujoco::studio + +#endif // MUJOCO_SRC_EXPERIMENTAL_STUDIO_APP_H_ diff --git a/src/experimental/studio/main.cc b/src/experimental/studio/main.cc new file mode 100644 index 00000000..925b0b03 --- /dev/null +++ b/src/experimental/studio/main.cc @@ -0,0 +1,66 @@ +// Copyright 2025 DeepMind Technologies Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Main entry point for the Filament-based MuJoCo renderer. + +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/absl/flags/flag.h" +#include "third_party/mujoco/google/runfiles/runfiles.h" +#include "experimental/studio/app.h" + +ABSL_FLAG(int, window_width, 1400, "Window width"); +ABSL_FLAG(int, window_height, 700, "Window height"); +ABSL_FLAG(std::string, model_file, "", "MuJoCo model file."); + +constexpr char kDataPath[] = "third_party/mujoco/google/filament/assets/data/"; + +static std::vector LoadAsset(std::string_view path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file.is_open()) { + return {}; + } + std::streampos file_size = file.tellg(); + file.seekg(0, std::ios::beg); + std::vector buffer(file_size); + if (!file.read(reinterpret_cast(buffer.data()), file_size)) { + return {}; + } + return buffer; +} + +int main(int argc, char** argv, char** envp) { + + const int width = absl::GetFlag(FLAGS_window_width); + const int height = absl::GetFlag(FLAGS_window_height); + const std::string ini_path = file::JoinPath(getenv("HOME"), ".mujoco.ini"); + mujoco::studio::App app(width, height, ini_path, LoadAsset); + + // If the model file is not specified, try to load it from the first argument + std::string model_file = absl::GetFlag(FLAGS_model_file); + if (model_file.empty() && argc > 1 && argv[1][0] != '-') model_file = argv[1]; + app.LoadModel(model_file); + while (app.Update()) { + app.Sync(); + app.BuildGui(); + app.Render(); + } + return 0; +}