Various UX improvements including:

- Add Toolbar and StatusBar.
- Reconfigure sections into Settings and Inspector groupings.
- Use docking for Settings/Inspector panels.
- Rename "Free" camera to "Tumble"; add WASD-based free camera.
- More dynamic layout of table-based elements.
- Remove "non-classic" UX mode.
- Switch to light mode by default.

PiperOrigin-RevId: 835174573
Change-Id: I1855caa79f00002e44ed2516631675727bdcb9f6
This commit is contained in:
Haroon Qureshi
2025-11-21 04:57:20 -08:00
committed by Copybara-Service
parent ae8272787f
commit a2002eb3d7
8 changed files with 1353 additions and 1161 deletions
File diff suppressed because it is too large Load Diff
+53 -46
View File
@@ -24,8 +24,10 @@
#include <unordered_map>
#include <vector>
#include <imgui.h>
#include <mujoco/mujoco.h>
#include "experimental/toolbox/helpers.h"
#include "experimental/toolbox/interaction.h"
#include "experimental/toolbox/physics.h"
#include "experimental/toolbox/renderer.h"
#include "experimental/toolbox/sim_profiler.h"
@@ -61,28 +63,19 @@ class App {
static int LoadAssetCallback(const char* path, void* user_data,
unsigned char** out, std::uint64_t* out_size);
enum Style {
kLight,
kDark,
};
// 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 camera_idx = toolbox::kTumbleCameraIdx;
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;
Style style = kLight;
using Dict = std::unordered_map<std::string, std::string>;
Dict ToDict() const;
@@ -90,13 +83,40 @@ class App {
};
// 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 should_exit = false;
// Windows.
bool help = false;
bool info = false;
bool chart_cpu_time = false;
bool chart_dimensions = false;
bool chart_counts = false;
bool chart_convergence = false;
bool settings_panel = true;
bool inspector_panel = true;
bool style_editor = false;
bool imgui_demo = false;
bool implot_demo = false;
bool modal_open = false;
bool load_popup = false;
// Controls.
bool perturb_active = false;
int speed_index = 0;
float cam_speed = 0.0f;
// Cached data.
float expected_label_width = 0;
std::vector<std::string> camera_names;
std::vector<std::string> speed_names;
// State.
int state_sig = 0;
std::vector<mjtNum> state;
// File dialogs.
char filename[1000] = "";
std::string last_load_file;
bool save_xml_popup = false;
std::string last_save_xml_file;
@@ -108,26 +128,6 @@ class App {
std::string last_print_model_file;
bool print_data_popup = false;
std::string last_print_data_file;
bool should_exit = 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];
// Data for StateGui
int state_sig = 0;
std::vector<mjtNum> state;
char filename[1000] = "";
};
void OnModelLoaded(std::string_view model_file);
@@ -135,28 +135,31 @@ class App {
void LoadSettings();
void SaveSettings();
void SetCamera(int idx);
void ChangeSpeed(int delta);
void SetSpeedIndex(int idx);
void HandleMouseEvents();
void HandleKeyboardEvents();
void MoveCamera(toolbox::CameraMotion motion, mjtNum reldx, mjtNum reldy);
void BuildGuiWithWindows();
void BuildGuiWithSections();
void SetupStyle(Style style);
ImVec4 ConfigureDockingLayout();
void MainMenuGui();
void ToolBarGui();
void SettingsGui();
void InspectorGui();
void StatusBarGui();
void InfoGui();
void HelpGui();
void MainMenuGui();
void FileDialogGui();
void SimulationGui();
void PlaybackGui();
void PhysicsGui();
void NoiseGui();
void RenderingGui();
void VisualizationGui();
void GroupsGui();
void WatchGui();
void SensorGui();
void ProfilerGui();
void StateGui();
void JointsGui();
void ControlsGui();
@@ -164,6 +167,10 @@ class App {
mjModel* Model() { return physics_->GetModel(); };
mjData* Data() { return physics_->GetData(); };
float GetExpectedLabelWidth();
std::vector<const char*> GetCameraNames();
std::vector<const char*> GetSpeedNames();
std::string ini_path_;
std::string model_file_;
+1 -1
View File
@@ -26,7 +26,7 @@
#include "experimental/studio/app.h"
ABSL_FLAG(int, window_width, 1400, "Window width");
ABSL_FLAG(int, window_height, 700, "Window height");
ABSL_FLAG(int, window_height, 720, "Window height");
ABSL_FLAG(std::string, model_file, "", "MuJoCo model file.");
static std::vector<std::byte> LoadAsset(std::string_view path) {
+80 -95
View File
@@ -15,8 +15,8 @@
#ifndef MUJOCO_SRC_EXPERIMENTAL_TOOLBOX_IMGUI_WIDGETS_H_
#define MUJOCO_SRC_EXPERIMENTAL_TOOLBOX_IMGUI_WIDGETS_H_
#include <cstring>
#include <optional>
#include <utility>
#include <string>
#include <type_traits>
#include <unordered_map>
@@ -37,6 +37,53 @@ void AppendIniSection(std::string& ini, const std::string& section,
KeyValues ReadIniSection(const std::string& contents,
const std::string& section);
// Helper class for setting ImGui style options; automatically resets the
// styles when going out of scope.
struct ScopedStyle {
ScopedStyle() = default;
~ScopedStyle() {
Reset();
}
ScopedStyle(const ScopedStyle&) = delete;
ScopedStyle& operator=(const ScopedStyle&) = delete;
ScopedStyle(ScopedStyle&& other) { Swap(other); }
ScopedStyle& operator=(ScopedStyle&& other) { Swap(other); return *this; }
void Swap(ScopedStyle& other) {
std::swap(num_colors, other.num_colors);
std::swap(num_vars, other.num_vars);
}
ScopedStyle& Color(ImGuiCol col, ImColor color) {
ImGui::PushStyleColor(col, (ImU32)color);
++num_colors;
return *this;
}
ScopedStyle& Var(ImGuiStyleVar var, float value) {
ImGui::PushStyleVar(var, value);
++num_vars;
return *this;
}
ScopedStyle& Var(ImGuiStyleVar var, const ImVec2& value) {
ImGui::PushStyleVar(var, value);
++num_vars;
return *this;
}
void Reset() {
ImGui::PopStyleVar(num_vars);
ImGui::PopStyleColor(num_colors);
num_colors = 0;
num_vars = 0;
}
int num_colors = 0;
int num_vars = 0;
};
// ImGui file dialog.
bool ImGui_FileDialog(char* buf, int len);
@@ -54,108 +101,46 @@ bool ImGui_Checkbox(const char* name, T& value) {
return res;
}
enum class ToggleKind {
// Solid when ON and transparent when OFF.
kButton,
// Slider which is right when ON and left when OFF.
kSlider,
};
template <typename T>
bool Toggle(const char* label, T& boolean,
ToggleKind kind = ToggleKind::kButton, bool set_width = true) {
bool ImGui_ButtonToggle(const char* label, T* boolean,
const ImVec2& size = ImVec2(0, 0)) {
static_assert(std::is_integral_v<T>, "Toggle only supports integral types.");
// Compute this width once and cache it. Only used when set_width is true.
static int toggle_width = []() {
int longest = 0;
const char* longest_label = "";
for (int i = 0; i < mjNVISFLAG; ++i) {
int length = static_cast<int>(strlen(mjVISSTRING[i][0]));
if (length > longest) {
longest_label = mjVISSTRING[i][0];
longest = length;
}
}
return ImGui::CalcTextSize(longest_label).x + 5;
}();
ImGui::PushID(label);
bool changed = false;
switch (kind) {
case ToggleKind::kButton: {
bool b = (boolean != 0);
bool transparent = !b;
// NOTE(matijak): Its nice to have the button trigger on click but this
// requires using the currently internal PressedOnClick flag and ButtonEx
// function. It looks like this API has been stable for a long time, but
// in case it changes in a way which breaks and is annoying to maintain we
// can revert to the else clause and remove the imgui_internal.h include.
// Note that the else clause overrides different style colors since the UI
// is more intuitive with different settings.
if constexpr (true) {
ImColor button = ImGui::GetStyle().Colors[ImGuiCol_Button];
if (transparent) button.Value.w = 0.0f;
ImGui::PushStyleColor(ImGuiCol_Button, (ImU32)button);
ImGui::PushStyleColor(ImGuiCol_ButtonActive, (ImU32)button);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, (ImU32)button);
// Button width is set via an explicit size parameter, not via the
// SetNextItemWidth function.
ImVec2 size = set_width ? ImVec2(toggle_width, 0) : ImVec2(0, 0);
changed = ImGui::ButtonEx(label, size, ImGuiButtonFlags_PressedOnClick);
if (changed) {
b = !b;
}
boolean = b;
ImGui::PopStyleColor(3);
} else {
if (transparent) {
ImColor button = ImGui::GetStyle().Colors[ImGuiCol_Button];
button.Value.w = 0.0f;
ImGui::PushStyleColor(ImGuiCol_Button, (ImU32)button);
}
// Button width is set via an explicit size parameter, not via the
// SetNextItemWidth function.
ImVec2 size = set_width ? ImVec2(toggle_width, 0) : ImVec2(0, 0);
changed = ImGui::Button(label, size);
if (changed) {
b = !b;
}
boolean = b;
if (transparent) {
ImGui::PopStyleColor(1);
}
}
} break;
case ToggleKind::kSlider: {
int i = (int)boolean;
const char* labels[2] = {label, label};
const ImGuiSliderFlags flags = ImGuiSliderFlags_NoInput;
if (set_width) ImGui::SetNextItemWidth(toggle_width);
changed = ImGui::SliderInt("", &i, 0, 1, labels[i], flags);
boolean = (i != 0);
} break;
ScopedStyle style;
if (!(*boolean)) {
ImColor button = ImGui::GetStyle().Colors[ImGuiCol_Button];
button.Value.w = 0.0f;
style.Color(ImGuiCol_Button, button);
}
ImGui::PopID();
if (ImGui::Button(label, size)) {
*boolean = !(*boolean);
return true;
}
return false;
}
template <typename T>
bool ImGui_SwitchToggle(const char* label, T* boolean,
const ImVec2& size = ImVec2(0, 0)) {
static_assert(std::is_integral_v<T>, "Toggle only supports integral types.");
int i = static_cast<int>(*boolean);
const ImGuiSliderFlags flags = ImGuiSliderFlags_NoInput;
if (size.x > 0) {
ImGui::SetNextItemWidth(size.x);
}
const bool changed = ImGui::SliderInt(label, &i, 0, 1, label, flags);
*boolean = (i != 0);
return changed;
}
inline bool ToggleBit(const char* label, int& flags, int flags_value,
ToggleKind kind = ToggleKind::kButton,
bool set_width = true) {
bool boolean = flags & flags_value;
bool changed = Toggle(label, boolean, kind, set_width);
inline bool ImGui_BitToggle(const char* label, int* flags, int flags_value,
const ImVec2& size = ImVec2(0, 0)) {
bool boolean = (*flags) & flags_value;
const bool changed = ImGui_ButtonToggle(label, &boolean, size);
if (changed) {
flags = boolean ? (flags | flags_value) : (flags & ~flags_value);
*flags = boolean ? ((*flags) | flags_value) : ((*flags) & ~flags_value);
}
return changed;
}
+60 -43
View File
@@ -213,7 +213,7 @@ void MovePerturb(const mjModel* m, const mjData* d, const mjvCamera* cam,
}
void MoveCamera(const mjModel* m, const mjData* d, mjvCamera* cam,
mjtMouse action, mjtNum reldx, mjtNum reldy) {
CameraMotion motion, mjtNum dx, mjtNum dy) {
if (cam->type == mjCAMERA_FIXED) {
return;
}
@@ -221,15 +221,51 @@ void MoveCamera(const mjModel* m, const mjData* d, mjvCamera* cam,
mjtNum headpos[3], forward[3], up[3], right[3];
mjtNum vec[3], dif[3], scl;
switch (action) {
case mjMOUSE_ROTATE_V:
case mjMOUSE_ROTATE_H:
cam->azimuth -= reldx * 180.0;
cam->elevation -= reldy * 180.0;
switch (motion) {
case CameraMotion::ZOOM:
// Zoom the camera towards the target by adjusting its distance to the
// target.
cam->distance -= mju_log(1 + cam->distance / m->stat.extent / 3) * dy *
9 * m->stat.extent;
break;
case mjMOUSE_MOVE_V:
case mjMOUSE_MOVE_H:
case CameraMotion::ORBIT:
cam->azimuth -= dx * 180.0;
cam->elevation -= dy * 180.0;
break;
case CameraMotion::TRUCK_PEDESTAL:
case CameraMotion::TRUCK_DOLLY:
if (cam->type == mjCAMERA_TRACKING) {
return;
}
mjv_cameraFrame(headpos, forward, up, nullptr, d, cam);
mju_cross(right, forward, up);
// y movement: either dolly (forward/back) or pedestal (up/down)
mju_addToScl3(cam->lookat,
(motion == CameraMotion::TRUCK_PEDESTAL) ? up : forward, dy);
// x movement: camera truck (left/right)
mju_addToScl3(cam->lookat, right, dx);
break;
case CameraMotion::PAN_TILT:
if (cam->type == mjCAMERA_TRACKING) {
return;
}
mjv_cameraFrame(headpos, forward, nullptr, nullptr, d, cam);
cam->azimuth -= dx * 180.0;
cam->elevation -= dy * 180.0;
mjv_cameraFrame(nullptr, forward, nullptr, nullptr, d, cam);
mju_addScl3(cam->lookat, headpos, forward, cam->distance);
break;
case CameraMotion::PLANAR_MOVE_V:
case CameraMotion::PLANAR_MOVE_H:
// do not move lookat point of tracking camera
if (cam->type == mjCAMERA_TRACKING) {
return;
@@ -237,7 +273,10 @@ void MoveCamera(const mjModel* m, const mjData* d, mjvCamera* cam,
// get camera info and align
mjv_cameraFrame(headpos, forward, nullptr, nullptr, d, cam);
AlignToCamera(vec, action, reldx, reldy, forward);
AlignToCamera(vec,
(motion == CameraMotion::PLANAR_MOVE_V) ? mjMOUSE_MOVE_V
: mjMOUSE_MOVE_H,
dx, dy, forward);
// compute scaling: rendered lookat displacement = mouse displacement
mju_sub3(dif, cam->lookat, headpos);
@@ -246,34 +285,6 @@ void MoveCamera(const mjModel* m, const mjData* d, mjvCamera* cam,
// move lookat point in opposite direction
mju_addToScl3(cam->lookat, vec, -scl);
break;
case mjMOUSE_ZOOM:
cam->distance -= mju_log(1 + cam->distance / m->stat.extent / 3) * reldy *
9 * m->stat.extent;
break;
case mjMOUSE_MOVE_V_REL:
case mjMOUSE_MOVE_H_REL:
// do not move lookat point of tracking camera
if (cam->type == mjCAMERA_TRACKING) {
return;
}
mjv_cameraFrame(headpos, forward, up, nullptr, d, cam);
mju_cross(right, forward, up);
// y-axis movement moves forward/backward (ie. camera dolly) on horizontal
// plane or up/down (ie. camera pedestal) on vertical plane
mju_addToScl3(cam->lookat, (action == mjMOUSE_MOVE_V_REL) ? up : forward,
reldy);
// x-axis movement strafes left/right (ie. camera truck)
mju_addToScl3(cam->lookat, right, reldx);
break;
default:
mjERROR("unexpected action %d", action);
}
// clamp camera parameters
@@ -589,20 +600,26 @@ PickResult Pick(const mjModel* m, const mjData* d, const mjvCamera* camera,
}
int SetCamera(const mjModel* m, mjvCamera* camera, int request_idx) {
// 0 = free, 1 = tracking, 2+ = fixed
int camera_idx = std::clamp(request_idx, 0, std::max(m->ncam + 1, 0));
if (camera_idx == 0) {
const int ncam = m ? m->ncam : 0;
const int camera_idx = std::clamp(request_idx, kTumbleCameraIdx, ncam - 1);
if (camera_idx == kTumbleCameraIdx) {
camera->type = mjCAMERA_FREE;
} else if (camera_idx == 1) {
camera->fixedcamid = -1;
} else if (camera_idx == kFreeCameraIdx) {
camera->type = mjCAMERA_FREE;
camera->distance = 2.0f;
camera->fixedcamid = -1;
} else if (camera_idx == kTrackingCameraIdx) {
if (camera->trackbodyid >= 0) {
camera->type = mjCAMERA_TRACKING;
camera->fixedcamid = -1;
} else {
camera->type = mjCAMERA_FREE;
}
camera->fixedcamid = -1;
} else {
camera->type = mjCAMERA_FIXED;
camera->fixedcamid = camera_idx - 2;
camera->fixedcamid = camera_idx;
}
return camera_idx;
+34 -11
View File
@@ -34,21 +34,44 @@ PickResult Pick(const mjModel* m, const mjData* d, const mjvCamera* camera,
float x, float y, float aspect_ratio,
const mjvOption* vis_options);
// Updates the camera according to the requested index using this convention:
// Indices for cameras that are not defined in the model.
static constexpr int kTumbleCameraIdx = -3;
static constexpr int kFreeCameraIdx = -2;
static constexpr int kTrackingCameraIdx = -1;
// Updates the camera according to the requested index.
//
// 0 : selects the free camera (not defined in the model)
// 1 : selects the tracking camera (also not defined in the model)
// 2+ : selects a camera in the model; e.g. index 2 => model.cam[0];
//
// The function returns the index of the used camera following the same
// convention. Note the returned index may differ from the request if the
// request was invalid (index was out of range or tracking camera was not
// available).
// The function returns the new index of the camera which may differ from the
// request if the request was invalid (e.g. request was out of range).
int SetCamera(const mjModel* m, mjvCamera* camera, int request_idx);
// Moves the camera according to the mouse action and relative displacement.
// Camera motions are either relative to a target or the camera itself.
//
// We use the following camera nomenclature:
// - Truck: moves the camera left/right along a horizontal plane.
// - Pedestal: moves the camera up/down along a vertical plane.
// - Dolly: moves the camera forward/backward along a horizontal plane.
// - Pan: turns the camera left/right.
// - Tilt: turns the camera upwards/downwards.
// - Zoom: moves the camera closer to or away from the target. This is
// different from dolly in that the movement is relative to the
// target. (It's also not actually a camera zoom, which is an
// action of lens of the camera, rather than the camera itself.)
// - Orbit: moves the camera around the target.
// - Planer: creates a horizontal or vertical plane based on the cameras
// position and orientation, then moves the camera along that plane.
enum class CameraMotion {
ZOOM,
ORBIT,
TRUCK_PEDESTAL,
TRUCK_DOLLY,
PAN_TILT,
PLANAR_MOVE_H,
PLANAR_MOVE_V,
};
void MoveCamera(const mjModel* m, const mjData* d, mjvCamera* cam,
mjtMouse action, mjtNum reldx, mjtNum reldy);
CameraMotion motion, mjtNum dx, mjtNum dy);
void InitPerturb(const mjModel* m, const mjData* d, const mjvCamera* cam,
mjvPerturb* pert, mjtPertBit active);
+9 -4
View File
@@ -20,6 +20,10 @@
namespace mujoco::toolbox {
// TO DO:
// - convergence profiler
// - solver iteration profiler
SimProfiler::SimProfiler() {
Clear();
}
@@ -118,9 +122,8 @@ void SimProfiler::Update(const mjModel* model, const mjData* data) {
}
void SimProfiler::Gui() {
const int plot_flags = 0;
if (ImPlot::BeginPlot("CPU Time", ImVec2(-1, 0), plot_flags)) {
void SimProfiler::CpuTimeGraph() {
if (ImPlot::BeginPlot("CPU Time", ImVec2(-1, 0))) {
ImPlot::SetupAxis(ImAxis_X1, "frame", ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxis(ImAxis_Y1, "msec", ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxisFormat(ImAxis_Y1, "%.2f");
@@ -139,8 +142,10 @@ void SimProfiler::Gui() {
-(int)cpu_other_.size());
ImPlot::EndPlot();
}
}
if (ImPlot::BeginPlot("Dimensions", ImVec2(-1, 0), plot_flags)) {
void SimProfiler::DimensionsGraph() {
if (ImPlot::BeginPlot("Dimensions", ImVec2(-1, 0))) {
ImPlot::SetupAxis(ImAxis_X1, "frame", ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxis(ImAxis_Y1, "count", ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxisFormat(ImAxis_Y1, "%.0f");
+2 -1
View File
@@ -33,7 +33,8 @@ class SimProfiler {
void Update(const mjModel* model, const mjData* data);
// Displays the profiling data using ImPlot.
void Gui();
void CpuTimeGraph();
void DimensionsGraph();
private:
std::vector<float> cpu_total_;