Display Bodies as a tree in Spec Explorer.

Display all other elements under "Elements" or "Assets" group.

Show properties for mjSpec, mjModel, and mjData for selected
elements.

PiperOrigin-RevId: 874614195
Change-Id: I128c2bec844983ac5d35a3a0b1e58b8a5b0109cb
This commit is contained in:
Haroon Qureshi
2026-02-24 07:57:01 -08:00
committed by Copybara-Service
parent 5fc580a8ba
commit 1d6ff2cece
9 changed files with 1290 additions and 226 deletions
+86 -108
View File
@@ -36,6 +36,7 @@
#include <mujoco/mujoco.h>
#include "experimental/platform/file_dialog.h"
#include "experimental/platform/gui.h"
#include "experimental/platform/gui_spec.h"
#include "experimental/platform/helpers.h"
#include "experimental/platform/imgui_widgets.h"
#include "experimental/platform/interaction.h"
@@ -132,6 +133,7 @@ void App::ClearModel() {
tmp_ = UiTempState();
load_error_ = "";
step_error_ = "";
edit_error_ = "";
}
void App::Recompile() {
@@ -257,6 +259,7 @@ void App::ResetPhysics() {
mj_resetData(model(), data());
mj_forward(model(), data());
step_error_ = "";
edit_error_ = "";
}
void App::UpdatePhysics() {
@@ -419,15 +422,42 @@ void App::ProcessPendingLoads() {
});
}
void App::SpecDeleteSelectedElement() {
spec_op_ = [this]() {
mjs_delete(spec(), tmp_.element);
if (tmp_.element->elemtype == mjOBJ_BODY &&
perturb_.select == tmp_.element_id) {
mjv_defaultPerturb(&perturb_);
}
tmp_.element = nullptr;
void App::SpecSelectElement(mjsElement* element) {
tmp_.element = element;
if (tmp_.element == nullptr) {
tmp_.element_id = -1;
} else {
tmp_.element_id = mjs_getId(tmp_.element);
// If we selected a body, then select the same body for perturb.
if (tmp_.element->elemtype == mjOBJ_BODY &&
perturb_.select != tmp_.element_id) {
mjv_defaultPerturb(&perturb_);
perturb_.select = tmp_.element_id;
}
}
}
void App::SpecDeleteElement(mjsElement* element) {
if (element == nullptr) {
return;
}
// Only bodies can be deleted for now...
if (element->elemtype != mjOBJ_BODY) {
edit_error_ = "WARNING: Only bodies can be deleted (for now...)";
return;
}
spec_op_ = [this, element]() {
mjs_delete(spec(), element);
if (tmp_.element == element) {
tmp_.element = nullptr;
tmp_.element_id = -1;
}
if (element->elemtype == mjOBJ_BODY) {
if (perturb_.select == mjs_getId(element)) {
mjv_defaultPerturb(&perturb_);
}
}
Recompile();
};
}
@@ -635,7 +665,7 @@ void App::HandleKeyboardEvents() {
} else if (ImGui_IsChordJustPressed(ImGuiKey_Backspace)) {
ResetPhysics();
} else if (ImGui_IsChordJustPressed(ImGuiKey_Delete)) {
SpecDeleteSelectedElement();
SpecDeleteElement(tmp_.element);
} else if (ImGui_IsChordJustPressed(ImGuiKey_PageUp)) {
SelectParentPerturb(model(), perturb_);
} else if (ImGui_IsChordJustPressed(ImGuiKey_F1)) {
@@ -896,7 +926,7 @@ void App::BuildGui() {
if (explorer_is_open && tmp_.element != nullptr) {
if (ImGui::Begin("Properties")) {
PropertiesGui();
SpecPropertiesGui();
}
ImGui::End();
}
@@ -1129,117 +1159,62 @@ void App::DataInspectorGui() {
ImGui::EndChild();
}
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 (!has_spec()) {
ImGui::Text("No mjSpec loaded.");
return;
}
const ImGuiTreeNodeFlags flags =
ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed;
auto on_delete = [this](mjsElement* element) { SpecDeleteElement(element); };
auto display_group = [this](mjtObj type, const std::string& prefix,
std::function<void()> delete_callback = {}) {
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) + ")";
}
const bool selected = (tmp_.element == element);
if (ImGui::Selectable(label.c_str(), selected,
ImGuiSelectableFlags_AllowOverlap)) {
tmp_.element = element;
tmp_.element_id = id;
}
if (selected && delete_callback) {
// Right-align the delete button.
const float button_width = ImGui::CalcTextSize(ICON_DELETE).x +
ImGui::GetStyle().FramePadding.x * 2.0f;
ImGui::SameLine(ImGui::GetWindowContentRegionMax().x - button_width);
if (ImGui::SmallButton(ICON_DELETE)) {
delete_callback();
}
}
element = mjs_nextElement(spec(), element);
}
};
if (ImGui::TreeNodeEx("Bodies", flags)) {
display_group(mjOBJ_BODY, "Body", [this] { SpecDeleteSelectedElement(); });
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();
}
// If we selected a body, then select the same body for the perturb object.
if (tmp_.element && tmp_.element->elemtype == mjOBJ_BODY &&
perturb_.select != tmp_.element_id) {
mjv_defaultPerturb(&perturb_);
perturb_.select = tmp_.element_id;
mjsElement* element = tmp_.element;
platform::SpecExplorerGui(&element, spec(), on_delete);
if (element != tmp_.element) {
SpecSelectElement(element);
}
}
void App::PropertiesGui() {
if (tmp_.element == nullptr) {
ImGui::Text("No element selected.");
return;
}
void App::SpecPropertiesGui() {
platform::ScopedStyle style;
if (ImGui::BeginTable("##PropertiesHeader", 2)) {
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 20);
ImGui::TableNextColumn();
ImGui::Text("%s", mju_type2Str(tmp_.element->elemtype));
ImGui::TableNextColumn();
if (tmp_.element->elemtype == mjOBJ_BODY) {
if (ImGui::SmallButton(ICON_DELETE)) {
SpecDeleteSelectedElement();
}
}
ImGui::EndTable();
ImGui::Text("%s", mju_type2Str(tmp_.element->elemtype));
ImGui::SameLine();
ImGui::Text("(%d)", tmp_.element_id);
ImGui::SameLine(120);
if (tmp_.spec_prop_mode == SpecPropertiesMode::kSpec) {
style.Color(ImGuiCol_Button, ImGuiCol_ButtonActive);
}
if (ImGui::SmallButton("S")) {
tmp_.spec_prop_mode = SpecPropertiesMode::kSpec;
}
ImGui::SetItemTooltip("Spec");
style.Reset();
ImGui::SameLine();
if (tmp_.spec_prop_mode == SpecPropertiesMode::kModel) {
style.Color(ImGuiCol_Button, ImGuiCol_ButtonActive);
}
if (ImGui::SmallButton("M")) {
tmp_.spec_prop_mode = SpecPropertiesMode::kModel;
}
ImGui::SetItemTooltip("Model");
style.Reset();
ImGui::SameLine();
if (tmp_.spec_prop_mode == SpecPropertiesMode::kData) {
style.Color(ImGuiCol_Button, ImGuiCol_ButtonActive);
}
if (ImGui::SmallButton("D")) {
tmp_.spec_prop_mode = SpecPropertiesMode::kData;
}
ImGui::SetItemTooltip("Data");
style.Reset();
ImGui::Separator();
switch (tmp_.element->elemtype) {
case mjOBJ_BODY:
platform::BodyPropertiesGui(model(), data(), tmp_.element,
tmp_.element_id);
break;
case mjOBJ_JOINT:
platform::JointPropertiesGui(model(), data(), tmp_.element,
tmp_.element_id);
break;
case mjOBJ_SITE:
platform::SitePropertiesGui(model(), data(), tmp_.element,
tmp_.element_id);
break;
default:
// ignore other types
break;
if (tmp_.spec_prop_mode == SpecPropertiesMode::kSpec) {
platform::ElementSpecGui(spec(), tmp_.element);
} else if (tmp_.spec_prop_mode == SpecPropertiesMode::kModel) {
platform::ElementModelGui(model(), tmp_.element);
} else {
platform::ElementDataGui(data(), tmp_.element);
}
}
@@ -1617,6 +1592,9 @@ void App::StatusBarGui() {
} else if (!load_error_.empty()) {
ImGui::SameLine();
ImGui::Text(" | Load Error: %s", load_error_.c_str());
} else if (!edit_error_.empty()) {
ImGui::SameLine();
ImGui::Text(" | Edit Error: %s", edit_error_.c_str());
}
ImGui::TableNextColumn();
+11 -2
View File
@@ -91,6 +91,12 @@ class App {
kModelFromBuffer,
};
enum class SpecPropertiesMode {
kSpec,
kModel,
kData,
};
// UI state that is persisted across application runs
struct UiState {
char watch_field[1000] = "qpos";
@@ -136,6 +142,7 @@ class App {
std::vector<std::string> speed_names;
// Spec Properties.
SpecPropertiesMode spec_prop_mode = SpecPropertiesMode::kSpec;
mjsElement* element = nullptr;
int element_id = -1;
@@ -209,9 +216,10 @@ class App {
void ModelOptionsGui();
void DataInspectorGui();
void SpecExplorerGui();
void PropertiesGui();
void SpecPropertiesGui();
void SpecDeleteSelectedElement();
void SpecSelectElement(mjsElement* element);
void SpecDeleteElement(mjsElement* element);
float GetExpectedLabelWidth();
std::vector<const char*> GetCameraNames();
@@ -228,6 +236,7 @@ class App {
std::string model_path_;
std::string load_error_;
std::string step_error_;
std::string edit_error_;
std::optional<std::string> pending_load_;
bool preserve_camera_on_load_ = false;
ModelKind model_kind_ = kEmptyModel;