From 97eb307ce95d67b3a35c8d08bdc7ec0e559a90bf Mon Sep 17 00:00:00 2001 From: Haroon Qureshi Date: Tue, 9 Dec 2025 07:27:19 -0800 Subject: [PATCH] First pass at mjSpec Explorer and Properties windows. Displays the list of bodies and joints from the mjSpec and displays values for the selected element. Functionality is very limited and a bit non-intuitive. However, we want to get this into our users hands to start getting feedback. PiperOrigin-RevId: 842228895 Change-Id: I2491cd5e0acaf622110e0f6b8406919024d3607a --- src/experimental/platform/gui.cc | 128 +++++++++++++++++++++++++-- src/experimental/platform/gui.h | 19 +++- src/experimental/studio/app.cc | 145 +++++++++++++++++++++++++++++-- src/experimental/studio/app.h | 8 +- 4 files changed, 281 insertions(+), 19 deletions(-) diff --git a/src/experimental/platform/gui.cc b/src/experimental/platform/gui.cc index 7419f9dc..f616476d 100644 --- a/src/experimental/platform/gui.cc +++ b/src/experimental/platform/gui.cc @@ -33,8 +33,8 @@ namespace mujoco::platform { static constexpr int kToolsBarHeight = 48; static constexpr int kStatusBarHeight = 32; static constexpr float kOptionsRelWidth = 0.22f; -static constexpr float kInspectorRelWidth = 0.18f; -static constexpr float kInfoRelHeight = 0.3f; +static constexpr float kInspectorRelWidth = 0.22f; +static constexpr float kStatsRelHeight = 0.3f; static ImVec2 GetFlexElementSize(int num_cols) { const float width = (ImGui::GetContentRegionAvail().x / num_cols) - @@ -83,14 +83,20 @@ ImVec4 ConfigureDockingLayout() { ImGui::DockBuilderSplitNode(main, ImGuiDir_Right, kInspectorRelWidth, &inspector, &main); - ImGuiID info = 0; - ImGui::DockBuilderSplitNode(inspector, ImGuiDir_Down, kInfoRelHeight, - &info, &inspector); + ImGuiID stats = 0; + ImGui::DockBuilderSplitNode(options, ImGuiDir_Down, kStatsRelHeight, + &stats, &options); + + ImGuiID properties = 0; + ImGui::DockBuilderSplitNode(inspector, ImGuiDir_Down, kStatsRelHeight, + &properties, &inspector); ImGui::DockBuilderDockWindow("Dockspace", main); ImGui::DockBuilderDockWindow("Options", options); + ImGui::DockBuilderDockWindow("Explorer", inspector); ImGui::DockBuilderDockWindow("Inspector", inspector); - ImGui::DockBuilderDockWindow("Info", info); + ImGui::DockBuilderDockWindow("Properties", properties); + ImGui::DockBuilderDockWindow("Stats", stats); ImGui::DockBuilderFinish(root); } @@ -848,7 +854,8 @@ void CountsGui(const mjModel* model, mjData* data) { } } -void InfoGui(const mjModel* model, const mjData* data, bool paused, float fps) { +void StatsGui(const mjModel* model, const mjData* data, bool paused, + float fps) { const int num_islands = std::clamp(data->nisland, 1, mjNISLAND); // compute solver error (maximum over islands) @@ -916,4 +923,111 @@ void InfoGui(const mjModel* model, const mjData* data, bool paused, float fps) { ImGui::Columns(); } +void BodyPropertiesGui(const mjModel* model, const mjData* data, + mjsElement* element, int id) { + const mjsBody* body = mjs_asBody(element); + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.4f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.6f); + + std::string name = *mjs_getName(body->element); + if (name.empty()) { + name = "(Body " + std::to_string(id) + ")"; + } + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.3f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.7f); + + ImGui::Text("Name"); + ImGui::Text("xpos[0]"); + ImGui::Text("xpos[1]"); + ImGui::Text("xpos[2]"); + ImGui::Text("xquat[0]"); + ImGui::Text("xquat[1]"); + ImGui::Text("xquat[2]"); + ImGui::Text("xquat[3]"); + ImGui::Text("mass"); + + ImGui::NextColumn(); + ImGui::Text("%s", name.c_str()); + ImGui::Text("%f", data->xpos[3*id+0]); + ImGui::Text("%f", data->xpos[3*id+1]); + ImGui::Text("%f", data->xpos[3*id+2]); + ImGui::Text("%f", data->xquat[4*id+0]); + ImGui::Text("%f", data->xquat[4*id+1]); + ImGui::Text("%f", data->xquat[4*id+2]); + ImGui::Text("%f", data->xquat[4*id+3]); + ImGui::Text("%f", model->body_mass[id]); +} + +void JointPropertiesGui(const mjModel* model, const mjData* data, + mjsElement* element, int id) { + const mjsJoint* joint = mjs_asJoint(element); + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.4f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.6f); + + std::string name = *mjs_getName(joint->element); + if (name.empty()) { + name = "(Joint " + std::to_string(id) + ")"; + } + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.3f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.7f); + ImGui::Text("Name"); + + ImGui::NextColumn(); + ImGui::Text("%s", name.c_str()); +} + +void SitePropertiesGui(const mjModel* model, const mjData* data, + mjsElement* element, int id) { + const mjsSite* site = mjs_asSite(element); + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.4f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.6f); + + std::string name = *mjs_getName(site->element); + if (name.empty()) { + name = "(Joint " + std::to_string(id) + ")"; + } + + ImGui::Columns(2); + ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.3f); + ImGui::SetColumnWidth(1, ImGui::GetWindowWidth() * 0.7f); + ImGui::Text("Name"); + ImGui::Text("site_xpos[0]"); + ImGui::Text("site_xpos[1]"); + ImGui::Text("site_xpos[2]"); + ImGui::Text("site_xmat[0]"); + ImGui::Text("site_xmat[1]"); + ImGui::Text("site_xmat[2]"); + ImGui::Text("site_xmat[3]"); + ImGui::Text("site_xmat[4]"); + ImGui::Text("site_xmat[5]"); + ImGui::Text("site_xmat[6]"); + ImGui::Text("site_xmat[7]"); + ImGui::Text("site_xmat[8]"); + + ImGui::NextColumn(); + ImGui::Text("%s", name.c_str()); + ImGui::Text("%f", data->site_xpos[3*id+0]); + ImGui::Text("%f", data->site_xpos[3*id+1]); + ImGui::Text("%f", data->site_xpos[3*id+2]); + ImGui::Text("%f", data->site_xmat[4*id+0]); + ImGui::Text("%f", data->site_xmat[4*id+1]); + ImGui::Text("%f", data->site_xmat[4*id+2]); + ImGui::Text("%f", data->site_xmat[4*id+3]); + ImGui::Text("%f", data->site_xmat[4*id+4]); + ImGui::Text("%f", data->site_xmat[4*id+5]); + ImGui::Text("%f", data->site_xmat[4*id+6]); + ImGui::Text("%f", data->site_xmat[4*id+7]); + ImGui::Text("%f", data->site_xmat[4*id+8]); +} + } // namespace mujoco::platform diff --git a/src/experimental/platform/gui.h b/src/experimental/platform/gui.h index 938d473f..da0f0ad5 100644 --- a/src/experimental/platform/gui.h +++ b/src/experimental/platform/gui.h @@ -49,8 +49,13 @@ void SetupTheme(GuiTheme theme); // are used to configure the simulation (e.g. PhysicsGui). // "Inspector": resizable section of the right; designed for inspecting // or manipulating mjData elements (e.g. ControlsGui). -// "Info": resizable section below the inspector; designed for displaying -// basic simulation information (e.g. InfoGui); hidden by default. +// "Explorer": secondary tab connected to the Inspector; designed for +// displaying the tree of mjSpec elements. +// "Stats": resizable section below the options; designed for displaying +// basic simulation statistics (e.g. StatsGui); hidden by default. +// "Properties": resizable section below the explorer; designed for displaying +// properties of mjSpec elements (e.g. BodyPropertiesGui); hidden by +// default. // // Returns the size and position of the remaining workspace area which can then // be used to place additional elements (e.g. floating charts). @@ -108,7 +113,15 @@ void CountsGui(const mjModel* model, mjData* data); // UX for displaying basic simulation information. Note that the pause state and // FPS needs to be tracked by the caller and passed here to be displayed. -void InfoGui(const mjModel* model, const mjData* data, bool paused, float fps); +void StatsGui(const mjModel* model, const mjData* data, bool paused, float fps); + +// UX for displaying properties of various mjSpec elements. +void BodyPropertiesGui(const mjModel* model, const mjData* data, + mjsElement* element, int id); +void JointPropertiesGui(const mjModel* model, const mjData* data, + mjsElement* element, int id); +void SitePropertiesGui(const mjModel* model, const mjData* data, + mjsElement* element, int id); } // namespace mujoco::platform diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc index b76a5daa..4cb32bf9 100644 --- a/src/experimental/studio/app.cc +++ b/src/experimental/studio/app.cc @@ -44,6 +44,7 @@ #include "experimental/platform/renderer.h" #include "experimental/platform/step_control.h" #include "experimental/platform/window.h" +#include "xml/xml.h" #include "xml/xml_api.h" #if defined(USE_FILAMENT_OPENGL) || defined(USE_FILAMENT_VULKAN) @@ -222,7 +223,10 @@ void App::ProcessPendingLoad() { if (model_file_.ends_with(".mjb")) { model_ = mj_loadModel(model_file_.c_str(), 0); } else if (model_file_.ends_with(".xml")) { - model_ = mj_loadXML(model_file_.c_str(), nullptr, err, sizeof(err)); + spec_ = ParseXML(model_file_.c_str(), nullptr, err, sizeof(err)); + if (spec_ && err[0] == 0) { + model_ = mj_compile(spec_, nullptr); + } } else { error_ = "Unknown model file type; expected .mjb or .xml."; } @@ -596,7 +600,7 @@ void App::HandleKeyboardEvents() { } else if (ImGui_IsChordJustPressed(ImGuiKey_F1)) { ToggleWindow(tmp_.help); } else if (ImGui_IsChordJustPressed(ImGuiKey_F2)) { - ToggleWindow(tmp_.info); + ToggleWindow(tmp_.stats); } else if (ImGui_IsChordJustPressed(ImGuiKey_F6)) { vis_options_.frame = (vis_options_.frame + 1) % mjNFRAME; } else if (ImGui_IsChordJustPressed(ImGuiKey_F7)) { @@ -806,6 +810,20 @@ void App::BuildGui() { DataInspectorGui(); } ImGui::End(); + + bool explorer_is_open = false; + if (ImGui::Begin("Explorer", &tmp_.inspector_panel)) { + explorer_is_open = true; + SpecExplorerGui(); + } + ImGui::End(); + + if (explorer_is_open && tmp_.element != nullptr) { + if (ImGui::Begin("Properties")) { + PropertiesGui(); + } + ImGui::End(); + } } if (tmp_.chart_cpu_time) { @@ -848,11 +866,11 @@ void App::BuildGui() { ImGui::End(); } - if (tmp_.info) { + if (tmp_.stats) { platform::ScopedStyle style; style.Var(ImGuiStyleVar_Alpha, 0.6f); - if (ImGui::Begin("Info", &tmp_.info)) { - platform::InfoGui(model_, data_, step_control_.IsPaused(), fps_); + if (ImGui::Begin("Stats", &tmp_.stats)) { + platform::StatsGui(model_, data_, step_control_.IsPaused(), fps_); } ImGui::End(); } @@ -931,6 +949,11 @@ void App::ModelOptionsGui() { } void App::DataInspectorGui() { + if (data_ == nullptr) { + ImGui::Text("No mjData loaded."); + return; + } + const float min_width = GetExpectedLabelWidth(); const ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed; @@ -966,6 +989,112 @@ void App::DataInspectorGui() { } } +void DisplayElementTree(mjsElement* element) { + const mjString* name = mjs_getName(element); + if (name->empty()) { + ImGui::Text("(unnamed)"); + } else { + ImGui::Text("%s", name->c_str()); + } +} + +void App::SpecExplorerGui() { + if (spec_ == nullptr) { + ImGui::Text("No mjSpec loaded."); + return; + } + + const ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth; + + auto display_group = [this](mjtObj type, const std::string& prefix) { + mjsElement* element = mjs_firstElement(spec_, type); + while (element) { + const int id = mjs_getId(element); + + const mjString* name = mjs_getName(element); + std::string label = *name; + if (label.empty()) { + label = "(" + prefix + " " + std::to_string(id) + ")"; + } + + if (ImGui::Selectable(label.c_str(), false)) { + tmp_.element = element; + tmp_.element_id = id; + } + + element = mjs_nextElement(spec_, element); + } + }; + + + if (ImGui::TreeNodeEx("Bodies", flags)) { + // We don't use `display_group` here because we do additional selection + // logic tied to the `perturb_` field. + mjsElement* element = mjs_firstElement(spec_, mjOBJ_BODY); + while (element) { + const int id = mjs_getId(element); + + const mjString* name = mjs_getName(element); + std::string label = *name; + if (label.empty()) { + label = "(Body " + std::to_string(id) + ")"; + } + + if (ImGui::Selectable(label.c_str(), (id == perturb_.select), + ImGuiSelectableFlags_AllowDoubleClick)) { + tmp_.element = element; + tmp_.element_id = id; + } + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + perturb_.select = id; + } + + element = mjs_nextElement(spec_, element); + } + ImGui::TreePop(); + } + + if (ImGui::TreeNodeEx("Joints", flags)) { + display_group(mjOBJ_JOINT, "Joint"); + ImGui::TreePop(); + } + + if (ImGui::TreeNodeEx("Sites", flags)) { + display_group(mjOBJ_SITE, "Site"); + ImGui::TreePop(); + } +} + +void App::PropertiesGui() { + if (tmp_.element == nullptr) { + ImGui::Text("No element selected."); + return; + } + + switch (tmp_.element->elemtype) { + case mjOBJ_BODY: + ImGui::Text("Body"); + ImGui::Separator(); + platform::BodyPropertiesGui(model_, data_, tmp_.element, tmp_.element_id); + break; + case mjOBJ_JOINT: + ImGui::Text("Joint"); + ImGui::Separator(); + platform::JointPropertiesGui(model_, data_, tmp_.element, + tmp_.element_id); + break; + case mjOBJ_SITE: + ImGui::Text("Site"); + ImGui::Separator(); + platform::SitePropertiesGui(model_, data_, tmp_.element, + tmp_.element_id); + break; + default: + // ignore other types + break; + } +} + void App::HelpGui() { ImGui::Columns(4); ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.35f); @@ -974,7 +1103,7 @@ void App::HelpGui() { ImGui::SetColumnWidth(3, ImGui::GetWindowWidth() * 0.1f); ImGui::Text("Help"); - ImGui::Text("Info"); + ImGui::Text("Stats"); ImGui::Text("Cycle Frames"); ImGui::Text("Cycle Labels"); ImGui::Text("Free Camera"); @@ -1364,8 +1493,8 @@ void App::MainMenuGui() { if (ImGui::MenuItem("Help", "F1", tmp_.help)) { ToggleWindow(tmp_.help); } - if (ImGui::MenuItem("Info", "F2", tmp_.info)) { - ToggleWindow(tmp_.info); + if (ImGui::MenuItem("Stats", "F2", tmp_.stats)) { + ToggleWindow(tmp_.stats); } ImGui::Separator(); if (ImGui::MenuItem("Style Editor", "", tmp_.style_editor)) { diff --git a/src/experimental/studio/app.h b/src/experimental/studio/app.h index 9b312d2e..cc27730a 100644 --- a/src/experimental/studio/app.h +++ b/src/experimental/studio/app.h @@ -84,7 +84,7 @@ class App { // Windows. bool help = false; - bool info = false; + bool stats = false; bool chart_cpu_time = false; bool chart_dimensions = false; bool chart_solver = false; @@ -106,6 +106,10 @@ class App { std::vector camera_names; std::vector speed_names; + // Spec Properties. + mjsElement* element = nullptr; + int element_id = -1; + // State. int state_sig = 0; std::vector state; @@ -152,6 +156,8 @@ class App { void FileDialogGui(); void ModelOptionsGui(); void DataInspectorGui(); + void SpecExplorerGui(); + void PropertiesGui(); float GetExpectedLabelWidth(); std::vector GetCameraNames();