Move the Simulation panel into the platform toolkit.

PiperOrigin-RevId: 965914672
Change-Id: Ib349b77055c57453780818dd4d8f48899b7024d5
This commit is contained in:
Yuval Tassa
2026-08-17 05:39:43 -07:00
committed by Copybara-Service
parent 0fa066237a
commit c232938233
7 changed files with 729 additions and 518 deletions
+16
View File
@@ -17,6 +17,7 @@
#include <tuple>
#include <mujoco/mujoco.h>
#include <mujoco/experimental/platform/sim/sim_history.h>
#include <mujoco/experimental/platform/sim/step_control.h>
#include "structs.h"
#include <pybind11/pybind11.h>
@@ -82,4 +83,19 @@ PYBIND11_MODULE(sim, m, pybind11::mod_gil_not_used()) {
.def("set_noise_parameters", &StepControl::SetNoiseParameters,
py::arg("noise_scale"), py::arg("noise_rate"),
"Sets the noise parameters.");
using SimHistory = mujoco::platform::SimHistory;
constexpr int max_history = 2048;
constexpr int max_bytes = 128 * 1024 * 1024; // 128 MiB
py::class_<SimHistory>(m, "SimHistory")
.def(py::init<>())
.def("init", &SimHistory::Init, py::arg("state_size"),
py::arg("max_history") = max_history,
py::arg("max_bytes") = max_bytes,
"Clears and initializes the history buffer to hold `state_size` "
"mjtNum states.")
.def("get_index", &SimHistory::GetIndex,
"Returns the current history offset (0 is the most recent state).")
.def("size", &SimHistory::Size,
"Returns the number of recorded states.");
}
@@ -101,6 +101,7 @@ class StudioApp:
self.model_path = model_path
self.step_control = sim.StepControl()
self.ux_state = ux.UxState()
self._setup_history()
self.status = f'Loaded: {os.path.basename(model_path)!r}'
return model, data
@@ -117,6 +118,8 @@ class StudioApp:
self.step_control = sim.StepControl()
self.ux_state = ux.UxState()
self.sim_history = sim.SimHistory()
self._setup_history()
self.theme = ux.GuiTheme.LIGHT
self.show_info = False
self.show_solver = False
@@ -209,6 +212,27 @@ class StudioApp:
"""Reset the physics."""
mujoco.mj_resetData(self.model, self.data)
mujoco.mj_forward(self.model, self.data)
self._setup_history()
def _setup_history(self) -> None:
"""(Re)initialize simulation history recording for the current model."""
ux.setup_history(
self.step_control, self.sim_history, self.ux_state, self.model,
self.data,
)
def reload_model(self) -> None:
"""Reload the current model from its file."""
if self.model_path:
self.load_model_from_file(self.model_path)
def align_camera(self, camera: mujoco.MjvCamera) -> None:
"""Recenter the camera on the model's home camera, else the free camera."""
cam_id = self.model.vis.global_.cameraid
if 0 <= cam_id < self.model.ncam:
self.ux_state.camera_index = ux.set_camera(self.model, camera, cam_id)
else:
mujoco.mjv_defaultFreeCamera(self.model, camera)
def apply_perturb(self, perturb: mujoco.MjvPerturb) -> None:
"""Apply perturbation the model."""
@@ -236,6 +260,10 @@ class StudioApp:
called instead of ``step_control.advance``. The function receives
``(model, data)`` and should step the simulation in-place.
"""
if self.ux_state.update_threadpool:
mujoco.mju_threadpool(self.data, self.ux_state.nthread)
self.ux_state.update_threadpool = False
self.apply_perturb(perturb)
if step_fn is not None:
@@ -389,6 +417,17 @@ class StudioApp:
)
imgui.Begin('Options')
# The Simulation panel draws its own collapsible section header.
ux.simulation_gui(
self.model,
self.data,
self.step_control,
self.sim_history,
self.ux_state,
self.reset_physics,
self.reload_model,
lambda: self.align_camera(camera),
)
if imgui.TreeNodeEx('Physics Settings', node_flags):
ux.physics_gui(self.model)
imgui.TreePop()
+79
View File
@@ -15,6 +15,7 @@
// Python bindings for MuJoCo platform UX components.
#include <array>
#include <span>
#include <string>
#include <tuple>
#include <vector>
@@ -23,6 +24,7 @@
#include <implot.h>
#include <mujoco/mujoco.h>
#include <mujoco/experimental/platform/helpers.h>
#include <mujoco/experimental/platform/sim/sim_history.h>
#include <mujoco/experimental/platform/sim/step_control.h>
#include <mujoco/experimental/platform/ux/gui.h>
#include <mujoco/experimental/platform/ux/interaction.h>
@@ -46,6 +48,12 @@ struct UxState {
// Read/edited by camera_selection_gui
int camera_index = mujoco::platform::kTumbleCameraIdx;
// Read/edited by simulation_gui.
int key_idx = 0;
int nthread = 0;
bool update_threadpool = false;
mujoco::platform::SimulationTimelineState timeline;
};
struct RenderFlags {
@@ -72,6 +80,9 @@ PYBIND11_MODULE(ux, m, pybind11::mod_gil_not_used()) {
.def_readwrite("state_sig", &UxState::state_sig)
.def_readwrite("watch_field_index", &UxState::watch_field_index)
.def_readwrite("camera_index", &UxState::camera_index)
.def_readwrite("key_idx", &UxState::key_idx)
.def_readwrite("nthread", &UxState::nthread)
.def_readwrite("update_threadpool", &UxState::update_threadpool)
.def_property(
"watch_field_name",
[](const UxState& self) {
@@ -128,6 +139,74 @@ PYBIND11_MODULE(ux, m, pybind11::mod_gil_not_used()) {
"Render the simulation stepping control GUI. Modifies "
"ux_state.speed_index.");
m.def(
"setup_history",
[](mujoco::platform::StepControl* step_control,
mujoco::platform::SimHistory* history, UxState& ux_state,
py::object model_obj, py::object data_obj) {
mjModel* model =
py::cast<mujoco::python::MjModelWrapper&>(model_obj).get();
mjData* data = py::cast<mujoco::python::MjDataWrapper&>(data_obj).get();
mujoco::platform::SimulationTimelineState* timeline =
&ux_state.timeline;
py::gil_scoped_release no_gil;
// Record every simulation step into the history buffer (in C++, so no
// Python is called per step). Matches the native Studio app.
history->Init(mj_stateSize(model, mjSTATE_INTEGRATION));
step_control->SetPostStepCallback(
[history, timeline](const mjModel* m, mjData* d) {
std::span<mjtNum> state = history->AddToHistory();
if (!state.empty()) {
mj_getState(m, d, state.data(), mjSTATE_INTEGRATION);
timeline->sim_head_time = d->time;
}
});
// Record the initial state and reset the scrubber.
std::span<mjtNum> state = history->AddToHistory();
if (!state.empty()) {
mj_getState(model, data, state.data(), mjSTATE_INTEGRATION);
}
*timeline = {};
timeline->sim_head_time = data->time;
},
py::arg("step_control"), py::arg("history"), py::arg("ux_state"),
py::arg("model"), py::arg("data"),
"Wire history recording: (re)initialize the buffer, install a per-step "
"recorder on step_control, record the current state and reset the "
"timeline. Call on model load and after a reset.");
m.def(
"simulation_gui",
[](py::object model_obj, py::object data_obj,
mujoco::platform::StepControl* step_control,
mujoco::platform::SimHistory* history, UxState& ux_state,
py::function reset, py::function reload, py::function align) {
mjModel* model =
py::cast<mujoco::python::MjModelWrapper&>(model_obj).get();
mjData* data = py::cast<mujoco::python::MjDataWrapper&>(data_obj).get();
// The GIL is held throughout: the callbacks call back into Python.
mujoco::platform::SimulationGuiContext ctx;
ctx.model = model;
ctx.data = data;
ctx.step_control = step_control;
ctx.history = history;
ctx.timeline = &ux_state.timeline;
ctx.speed_index = &ux_state.speed_index;
ctx.key_idx = &ux_state.key_idx;
ctx.nthread = &ux_state.nthread;
ctx.update_threadpool = &ux_state.update_threadpool;
ctx.reset = [&reset]() { reset(); };
ctx.reload = [&reload]() { reload(); };
ctx.align = [&align]() { align(); };
mujoco::platform::SimulationGui(ctx);
},
py::arg("model"), py::arg("data"), py::arg("step_control"),
py::arg("history"), py::arg("ux_state"), py::arg("reset"),
py::arg("reload"), py::arg("align"),
"Render the full Simulation panel: reset/reload/align, run/pause, speed, "
"the history scrubber, keyframes and thread count. The three callbacks "
"are invoked for the corresponding buttons.");
m.def(
"theme_select_gui",
[](mujoco::platform::GuiTheme theme) {
+503
View File
@@ -19,8 +19,10 @@
#include <cmath>
#include <cstdio>
#include <limits>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include <imgui.h>
@@ -28,6 +30,7 @@
#include <implot.h>
#include <mujoco/mujoco.h>
#include "experimental/platform/helpers.h"
#include "experimental/platform/sim/sim_history.h"
#include "experimental/platform/sim/sim_profiler.h"
#include "experimental/platform/sim/step_control.h"
#include "experimental/platform/ux/imgui_widgets.h"
@@ -606,6 +609,506 @@ void SetSpeedIndex(StepControl* step_control, int& speed_index,
step_control->SetSpeed(speed);
}
void LoadHistoryFrame(SimHistory& history, StepControl& step_control,
const mjModel* model, mjData* data, int index) {
std::span<mjtNum> state = history.SetIndex(index);
if (!state.empty()) {
// Pause simulation when entering history mode.
step_control.SetPauseState(StepControl::PauseState::kNormalPaused);
mj_setState(model, data, state.data(), mjSTATE_INTEGRATION);
mj_forward(model, data);
}
}
static std::string FormatTimelineTime(double time_in_s) {
if (time_in_s < 0.0) time_in_s = 0.0;
char buf[64];
if (time_in_s == 0.0) {
return "0.00s";
} else if (time_in_s < 1e-3) {
// Microseconds range.
const double t_us = time_in_s * 1e6;
if (t_us >= 100.0) {
std::snprintf(buf, sizeof(buf), "%.0f\xC2\xB5s", t_us);
} else if (t_us >= 10.0) {
std::snprintf(buf, sizeof(buf), "%.1f\xC2\xB5s", t_us);
} else {
std::snprintf(buf, sizeof(buf), "%.2f\xC2\xB5s", t_us);
}
} else if (time_in_s < 1.0) {
// Milliseconds range.
const double t_ms = time_in_s * 1e3;
if (t_ms >= 100.0) {
std::snprintf(buf, sizeof(buf), "%.0fms", t_ms);
} else if (t_ms >= 10.0) {
std::snprintf(buf, sizeof(buf), "%.1fms", t_ms);
} else {
std::snprintf(buf, sizeof(buf), "%.2fms", t_ms);
}
} else {
// Seconds range.
if (time_in_s >= 100.0) {
std::snprintf(buf, sizeof(buf), "%.0fs", time_in_s);
} else if (time_in_s >= 10.0) {
std::snprintf(buf, sizeof(buf), "%.1fs", time_in_s);
} else {
std::snprintf(buf, sizeof(buf), "%.2fs", time_in_s);
}
}
return buf;
}
void TimelineScrubberGui(const mjModel* model, mjData* data,
StepControl& step_control, SimHistory& history,
SimulationTimelineState& timeline) {
// Timeline scrubber: spine + sliding knob widget.
const double max_time = timeline.sim_head_time;
const int hist_size = history.Size();
const int hist_min = 1 - hist_size; // most negative index (oldest)
const int hist_max = 0; // index 0 = most recent
int current_index = history.GetIndex();
const bool locked = (hist_size <= 1);
// Compute current timestamp for LH label.
double curr_time =
((data != nullptr) && current_index == history.GetIndex())
? data->time
: (max_time +
current_index *
((model != nullptr) ? model->opt.timestep : 0.002));
if (curr_time < 0.0) curr_time = 0.0;
const int curr_step = ((model != nullptr) && model->opt.timestep > 0)
? static_cast<int>(std::round(
curr_time / model->opt.timestep))
: 0;
const int max_step =
((model != nullptr) && model->opt.timestep > 0)
? static_cast<int>(std::round(max_time / model->opt.timestep))
: 0;
// Both LH and RH labels aim to be at 3 digits, switching units
// independently.
std::string lh_top_str = FormatTimelineTime(curr_time);
std::string rh_top_str = FormatTimelineTime(max_time);
std::string lh_bot_str = "(" + std::to_string(curr_step) + ")";
std::string rh_bot_str = "(" + std::to_string(max_step) + ")";
const char* lh_top_label = lh_top_str.c_str();
const char* rh_top_label = rh_top_str.c_str();
const char* lh_bot_label = lh_bot_str.c_str();
const char* rh_bot_label = rh_bot_str.c_str();
// Use slightly smaller text for timeline labels (e.g. 90% scale).
ImGui::SetWindowFontScale(0.9f);
const ImVec2 cursor = ImGui::GetCursorScreenPos();
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float base_label_w =
std::max(ImGui::CalcTextSize("88.8 ms").x,
ImGui::CalcTextSize("88.8 \xC2\xB5s").x);
const float curr_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (curr_lh_w > timeline.lh_width) {
timeline.lh_width = curr_lh_w;
}
const float curr_rh_w =
std::max({base_label_w, ImGui::CalcTextSize(rh_top_label).x,
ImGui::CalcTextSize(rh_bot_label).x});
if (curr_rh_w > timeline.rh_width) {
timeline.rh_width = curr_rh_w;
}
const float lh_box_w = timeline.lh_width;
const float rh_box_w = timeline.rh_width;
const float track_w =
std::max(10.0f, ImGui::GetContentRegionAvail().x - lh_box_w -
rh_box_w - spacing * 2.0f);
const float spine_h = 3.0f;
const float knob_w = 12.0f;
const float knob_h = 27.0f; // 1.5x taller than 18.0f
const float text_line_h = ImGui::GetTextLineHeight();
const float custom_line_spacing =
1.0f; // Decreased spacing between the two lines
const float text_2lines_h = text_line_h * 2.0f + custom_line_spacing;
const float total_h = std::max(knob_h, text_2lines_h);
// Vertical center of the track row.
const float row_center_y = cursor.y + total_h * 0.5f;
const float track_x0 = cursor.x + lh_box_w + spacing;
const float spine_y0 = row_center_y - spine_h * 0.5f;
const float spine_y1 = row_center_y + spine_h * 0.5f;
const float spine_x0 = track_x0;
const float spine_x1 = track_x0 + track_w;
// Compute knob position [0,1] along the spine.
float t = 1.0f; // Default: pinned to right end.
if (!locked) {
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
}
const float knob_cx = spine_x0 + t * (track_w - knob_w) + knob_w * 0.5f;
// Invisible interaction button over the full track area.
ImGui::SetCursorScreenPos(
ImVec2(track_x0, row_center_y - total_h * 0.5f));
ImGui::InvisibleButton("##Scrubber", ImVec2(track_w, total_h));
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
// Handle dragging.
if (!locked && (active || (hovered && ImGui::IsMouseDown(
ImGuiMouseButton_Left)))) {
const float mouse_x = ImGui::GetIO().MousePos.x;
if (!timeline.scrubber_active) {
timeline.scrubber_active = true;
if (std::abs(mouse_x - knob_cx) <= knob_w) {
timeline.scrubber_grab_offset = mouse_x - knob_cx;
} else {
timeline.scrubber_grab_offset = 0.0f;
}
}
const float effective_mouse_x = mouse_x - timeline.scrubber_grab_offset;
const float raw_t = (effective_mouse_x - spine_x0 - knob_w * 0.5f) /
std::max(1.0f, track_w - knob_w);
const float clamped_t = std::clamp(raw_t, 0.0f, 1.0f);
int new_index = current_index;
const float snap_eps = std::max(
0.02f, std::min(0.05f, 8.0f / std::max(1.0f, track_w - knob_w)));
if (clamped_t <= snap_eps ||
effective_mouse_x <= spine_x0 + knob_w * 0.5f + 6.0f) {
new_index = hist_min;
} else if (clamped_t >= 1.0f - snap_eps ||
effective_mouse_x >= spine_x1 - knob_w * 0.5f - 6.0f) {
new_index = hist_max;
} else {
const float inner_t =
(clamped_t - snap_eps) / (1.0f - 2.0f * snap_eps);
new_index =
hist_min +
static_cast<int>(std::round(inner_t * (hist_max - hist_min)));
}
if (new_index != current_index) {
LoadHistoryFrame(history, step_control, model, data, new_index);
current_index = new_index;
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
if ((data != nullptr) && current_index == history.GetIndex()) {
curr_time = data->time;
if (curr_time < 0.0) curr_time = 0.0;
lh_top_str = FormatTimelineTime(curr_time);
lh_top_label = lh_top_str.c_str();
const int updated_curr_step =
((model != nullptr) && model->opt.timestep > 0)
? static_cast<int>(
std::round(curr_time / model->opt.timestep))
: 0;
lh_bot_str = "(" + std::to_string(updated_curr_step) + ")";
lh_bot_label = lh_bot_str.c_str();
const float updated_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (updated_lh_w > timeline.lh_width) {
timeline.lh_width = updated_lh_w;
}
}
}
} else {
timeline.scrubber_active = false;
}
// Draw LH labels (two lines, both right-aligned).
const float text_y0 = row_center_y - text_2lines_h * 0.5f;
const float text_y1 = text_y0 + text_line_h + custom_line_spacing;
const float lh_top_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_top_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_top_x, text_y0));
ImGui::TextUnformatted(lh_top_label);
const float lh_bot_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_bot_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_bot_x, text_y1));
ImGui::TextUnformatted(lh_bot_label);
// Draw RH labels (two lines, both left-aligned).
const float rh_x0 = track_x0 + track_w + spacing;
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y0));
ImGui::TextUnformatted(rh_top_label);
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y1));
ImGui::TextUnformatted(rh_bot_label);
// Restore window font scale.
ImGui::SetWindowFontScale(1.0f);
// Draw spine and knob on the draw list.
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImGuiStyle& style = ImGui::GetStyle();
// Spine.
const ImVec4 scrollbar_bg =
ImGui::GetStyle().Colors[ImGuiCol_ScrollbarBg];
ImGuiCol bg_col_idx;
if (active) {
bg_col_idx = ImGuiCol_FrameBgActive;
} else if (hovered) {
bg_col_idx = ImGuiCol_FrameBgHovered;
} else {
bg_col_idx = (scrollbar_bg.w > 0.01f) ? ImGuiCol_ScrollbarBg
: ImGuiCol_FrameBg;
}
const ImU32 spine_col = ImGui::GetColorU32(bg_col_idx);
dl->AddRectFilled(ImVec2(spine_x0, spine_y0),
ImVec2(spine_x1, spine_y1), spine_col,
spine_h * 0.5f);
// Knob rectangle.
const float knob_x0 = knob_cx - knob_w * 0.5f;
const float knob_x1 = knob_cx + knob_w * 0.5f;
const float knob_y0 = row_center_y - knob_h * 0.5f;
const float knob_y1 = row_center_y + knob_h * 0.5f;
ImU32 knob_col;
if (active) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrabActive);
} else if (hovered) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
} else {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
}
dl->AddRectFilled(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
knob_col, style.GrabRounding);
// Knob border.
dl->AddRect(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
ImGui::GetColorU32(ImGuiCol_Border), style.GrabRounding);
// Advance layout cursor past this row.
ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + total_h));
ImGui::Dummy(ImVec2(0.0f, 0.0f));
}
void SimulationGui(const SimulationGuiContext& ctx) {
const ImGuiChildFlags child_flags =
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysAutoResize;
const ImGuiTreeNodeFlags node_flags =
ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed;
ImGui::BeginChild("SimulationGui", {0, 0}, child_flags);
if (SectionHeader(
"Simulation", node_flags | ImGuiTreeNodeFlags_DefaultOpen, 0.65f)) {
ImGui::PushID("SimSection");
const float slider_w = -ImGui::CalcTextSize(" Keyframe").x -
ImGui::GetStyle().ItemInnerSpacing.x;
bool is_dark = ImGui::GetStyle().Colors[ImGuiCol_WindowBg].x < 0.5f;
const ImColor green =
is_dark ? ImColor(40, 125, 60, 255) : ImColor(40, 180, 40, 255);
const ImColor yellow =
is_dark ? ImColor(158, 115, 18, 255) : ImColor(255, 215, 0, 255);
// Reset / Reload / Align buttons.
{
char reset_label[32];
std::snprintf(reset_label, sizeof(reset_label), "%s Reset",
ICON_FA_UNDO);
char reload_label[32];
std::snprintf(reload_label, sizeof(reload_label), "%s Reload",
ICON_FA_REFRESH);
char align_label[32];
std::snprintf(align_label, sizeof(align_label), "%s Align",
ICON_FA_CROSSHAIRS);
const float avail = ImGui::GetContentRegionAvail().x;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float btn_w = (avail - spacing * 2) / 3.0f;
if (ImGui::Button(reset_label, ImVec2(btn_w, 0))) {
ctx.reset();
}
ImGui::SameLine();
if (ImGui::Button(reload_label, ImVec2(btn_w, 0))) {
ctx.reload();
}
ImGui::SameLine();
if (ImGui::Button(align_label, ImVec2(btn_w, 0))) {
ctx.align();
}
}
// Pause / Run toggle.
{
ImGui::Spacing();
char pause_label[32];
std::snprintf(pause_label, sizeof(pause_label), "%s Pause",
ICON_FA_PAUSE);
char run_label[32];
std::snprintf(run_label, sizeof(run_label), "%s Run",
ICON_FA_PLAY);
bool paused = ctx.step_control->GetPauseState() != StepControl::PauseState::kUnpaused;
bool running = ctx.step_control->GetPauseState() == StepControl::PauseState::kUnpaused;
const float avail = ImGui::GetContentRegionAvail().x;
const float half = avail * 0.5f;
const float h = ImGui::GetFrameHeight() * 1.4f;
ImGui::SetWindowFontScale(1.3f);
if (ImGui_ColorButtonEx(pause_label, paused, yellow,
ImDrawFlags_RoundCornersLeft,
ImVec2(half, h))) {
ctx.step_control->SetPauseState(StepControl::PauseState::kNormalPaused);
}
ImGui::SameLine(0.f, 0.f);
if (ImGui_ColorButtonEx(run_label, running, green,
ImDrawFlags_RoundCornersRight,
ImVec2(half, h))) {
ctx.step_control->SetPauseState(StepControl::PauseState::kUnpaused);
}
ImGui::SetWindowFontScale(1.0f);
}
// Speed slider.
{
const int max_idx = kPercentRealTime.size() - 1;
int slider_val = max_idx - (*ctx.speed_index);
float speed_pct = std::stof(kPercentRealTime[(*ctx.speed_index)]);
char fmt[64];
const float desired = ctx.step_control->GetSpeed();
const float measured = ctx.step_control->GetSpeedMeasured();
bool misaligned = std::abs(measured - desired) > 0.1f * desired;
if (misaligned) {
std::snprintf(fmt, sizeof(fmt), "%.1f%%%% (%.1f%%%%)", speed_pct,
measured);
} else {
std::snprintf(fmt, sizeof(fmt), "%.1f%%%%", speed_pct);
}
ImGui::SetNextItemWidth(slider_w);
if (ImGui::SliderInt("Speed", &slider_val, 0, max_idx, fmt)) {
SetSpeedIndex(ctx.step_control, *ctx.speed_index, max_idx - slider_val);
}
if (misaligned) {
ImGui::SetItemTooltip("%s", "Desired Speed (Measured Speed)");
} else {
ImGui::SetItemTooltip("%s", "Percent of real-time");
}
}
// History controls (Frame Scrubber).
{
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
char prev_label[32];
std::snprintf(prev_label, sizeof(prev_label), "%s Step Back",
ICON_FA_CARET_LEFT);
char next_label[32];
std::snprintf(next_label, sizeof(next_label), "%s Step Fwd",
ICON_FA_CARET_RIGHT);
const float avail = ImGui::GetContentRegionAvail().x;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float btn_w = (avail - spacing) / 2.0f;
if (ImGui::Button(prev_label, ImVec2(btn_w, 0))) {
LoadHistoryFrame(*ctx.history, *ctx.step_control, ctx.model, ctx.data, ctx.history->GetIndex() - 1);
}
ImGui::SetItemTooltip("%s", "Load previous frame from history");
ImGui::SameLine();
if (ImGui::Button(next_label, ImVec2(btn_w, 0))) {
if (ctx.history->GetIndex() == 0) {
ctx.step_control->RequestSingleStep();
} else {
LoadHistoryFrame(*ctx.history, *ctx.step_control, ctx.model, ctx.data, ctx.history->GetIndex() + 1);
}
}
ImGui::SetItemTooltip("%s", "Load next frame from history / Single step");
// Timeline scrubber.
TimelineScrubberGui(ctx.model, ctx.data, *ctx.step_control, *ctx.history, *ctx.timeline);
}
// Keyframe controls.
if (ctx.model->nkey > 0) {
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
{
char key_fmt[128];
const char* key_name = mj_id2name(ctx.model, mjOBJ_KEY, (*ctx.key_idx));
if (key_name) {
std::snprintf(key_fmt, sizeof(key_fmt), "%s", key_name);
} else {
std::snprintf(key_fmt, sizeof(key_fmt), "Key %d", (*ctx.key_idx));
}
ImGui::SetNextItemWidth(slider_w);
ImGui::SliderInt("Keyframe", &(*ctx.key_idx), 0, ctx.model->nkey - 1,
key_fmt);
}
// Keyframe buttons.
{
char load_label[32];
std::snprintf(load_label, sizeof(load_label), "%s Load key",
ICON_FA_DOWNLOAD);
char save_label[32];
std::snprintf(save_label, sizeof(save_label), "%s Save key",
ICON_FA_UPLOAD);
char copy_label[32];
std::snprintf(copy_label, sizeof(copy_label), "%s Copy key",
ICON_FA_COPY);
const float avail = ImGui::GetContentRegionAvail().x;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float btn_w = (avail - spacing * 2) / 3.0f;
if (ImGui::Button(load_label, ImVec2(btn_w, 0))) {
mj_resetDataKeyframe(ctx.model, ctx.data, (*ctx.key_idx));
mj_forward(ctx.model, ctx.data);
}
ImGui::SetItemTooltip("%s", "Load selected keyframe to active state");
ImGui::SameLine();
if (ImGui::Button(save_label, ImVec2(btn_w, 0))) {
mj_setKeyframe(ctx.model, ctx.data, (*ctx.key_idx));
}
ImGui::SetItemTooltip("%s", "Save active state to selected keyframe");
ImGui::SameLine();
if (ImGui::Button(copy_label, ImVec2(btn_w, 0))) {
std::string str = KeyframeToString(ctx.model, ctx.data, false);
MaybeSaveToClipboard(str);
}
ImGui::SetItemTooltip(
"%s", "Copy selected keyframe to clipboard as MJCF XML");
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Thread control.
ImGui::SetNextItemWidth(slider_w);
ImGui::BeginDisabled(std::thread::hardware_concurrency() <= 1);
if (ImGui::SliderInt("Threads", &(*ctx.nthread), 0, 8, "%d worker threads")) {
(*ctx.update_threadpool) = true;
}
ImGui::EndDisabled();
ImGui::SetItemTooltip("%s", "Number of worker threads in threadpool");
ImGui::Spacing();
ImGui::PopID();
ImGui::TreePop();
}
ImGui::EndChild();
}
bool ThemeSelectGui(GuiTheme* theme, const ImVec2& size) {
static constexpr const char* ICON_DARKMODE = ICON_FA_CIRCLE;
static constexpr const char* ICON_LIGHTMODE = ICON_FA_CIRCLE_O;
+47
View File
@@ -24,11 +24,13 @@
// mjvOption, etc. But, some functions take additional arguments as needed.
#include <array>
#include <functional>
#include <string>
#include <vector>
#include <imgui.h>
#include <mujoco/mujoco.h>
#include "experimental/platform/sim/sim_history.h"
#include "experimental/platform/sim/sim_profiler.h"
#include "experimental/platform/sim/step_control.h"
@@ -89,6 +91,51 @@ void StepControlGui(StepControl* step_control, int& speed_index);
void SetSpeedIndex(StepControl* step_control, int& speed_index,
int request_idx);
// Loads history frame `index` into `data`, pausing the simulation. A no-op if
// the requested frame is empty.
void LoadHistoryFrame(SimHistory& history, StepControl& step_control,
const mjModel* model, mjData* data, int index);
// Persistent state of the timeline scrubber: the time at the head of history,
// the (monotonically-growing) label box widths, and the current drag.
struct SimulationTimelineState {
double sim_head_time = 0.0;
float lh_width = 0.0f;
float rh_width = 0.0f;
bool scrubber_active = false;
float scrubber_grab_offset = 0.0f;
};
// The timeline scrubber row: a spine with a draggable knob that scrubs through
// the simulation history. Used by the Simulation panel and the toolbar.
void TimelineScrubberGui(const mjModel* model, mjData* data,
StepControl& step_control, SimHistory& history,
SimulationTimelineState& timeline);
// Everything the Simulation panel reads or drives. All pointers are owned by
// the caller and edited in place; the callbacks perform application actions the
// panel cannot do on its own.
struct SimulationGuiContext {
mjModel* model = nullptr; // non-const: saving a keyframe writes to the model
mjData* data = nullptr;
StepControl* step_control = nullptr;
SimHistory* history = nullptr;
SimulationTimelineState* timeline = nullptr;
int* speed_index = nullptr;
int* key_idx = nullptr;
int* nthread = nullptr;
bool* update_threadpool = nullptr;
std::function<void()> reset; // reset the physics state
std::function<void()> reload; // reload the model
std::function<void()> align; // recenter the camera on the model
};
// The Simulation panel: reset / reload / align, a pause-run toggle, the speed
// slider, the history scrubber, keyframe controls and the thread count. This is
// a reusable view over the platform simulation objects; it holds no application
// state of its own.
void SimulationGui(const SimulationGuiContext& ctx);
// UX for selecting the GUI theme.
bool ThemeSelectGui(GuiTheme* theme, const ImVec2& size = ImVec2(0, 0));
+44 -510
View File
@@ -148,11 +148,11 @@ void App::Recompile() {
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
}
}
tmp_.sim_head_time_ = has_data() ? data()->time : 0.0;
tmp_.timeline_lh_width = 0.0f;
tmp_.timeline_rh_width = 0.0f;
tmp_.scrubber_active = false;
tmp_.scrubber_grab_offset = 0.0f;
timeline_.sim_head_time = has_data() ? data()->time : 0.0;
timeline_.lh_width = 0.0f;
timeline_.rh_width = 0.0f;
timeline_.scrubber_active = false;
timeline_.scrubber_grab_offset = 0.0f;
}
void App::RequestModelLoad(std::string model_file) {
@@ -246,11 +246,11 @@ void App::OnModelLoaded(std::string filename, ModelKind model_kind) {
mj_getState(model, data(), state.data(), mjSTATE_INTEGRATION);
}
}
tmp_.sim_head_time_ = has_data() ? data()->time : 0.0;
tmp_.timeline_lh_width = 0.0f;
tmp_.timeline_rh_width = 0.0f;
tmp_.scrubber_active = false;
tmp_.scrubber_grab_offset = 0.0f;
timeline_.sim_head_time = has_data() ? data()->time : 0.0;
timeline_.lh_width = 0.0f;
timeline_.rh_width = 0.0f;
timeline_.scrubber_active = false;
timeline_.scrubber_grab_offset = 0.0f;
if (!preserve_camera_on_load_) {
const int model_cam = model->vis.global.cameraid;
@@ -323,11 +323,11 @@ void App::ResetPhysics() {
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
}
}
tmp_.sim_head_time_ = has_data() ? data()->time : 0.0;
tmp_.timeline_lh_width = 0.0f;
tmp_.timeline_rh_width = 0.0f;
tmp_.scrubber_active = false;
tmp_.scrubber_grab_offset = 0.0f;
timeline_.sim_head_time = has_data() ? data()->time : 0.0;
timeline_.lh_width = 0.0f;
timeline_.rh_width = 0.0f;
timeline_.scrubber_active = false;
timeline_.scrubber_grab_offset = 0.0f;
step_error_ = "";
edit_error_ = "";
}
@@ -394,7 +394,7 @@ void App::UpdatePhysics() {
std::span<mjtNum> state = sim_history_.AddToHistory();
if (!state.empty()) {
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
tmp_.sim_head_time_ = data()->time;
timeline_.sim_head_time = data()->time;
}
}
}
@@ -418,20 +418,13 @@ void App::PostStep(const mjModel* m, mjData* d) {
std::span<mjtNum> state = sim_history_.AddToHistory();
if (!state.empty()) {
mj_getState(m, d, state.data(), mjSTATE_INTEGRATION);
tmp_.sim_head_time_ = d->time;
timeline_.sim_head_time = d->time;
}
}
void App::LoadHistory(int offset) {
std::span<mjtNum> state = sim_history_.SetIndex(offset);
if (!state.empty()) {
// Pause simulation when entering history mode.
step_control_.SetPauseState(PauseState::kNormalPaused);
// Load the state into the data buffer.
mj_setState(model(), data(), state.data(), mjSTATE_INTEGRATION);
mj_forward(model(), data());
}
platform::LoadHistoryFrame(sim_history_, step_control_, model(), data(),
offset);
}
bool App::Update() {
@@ -1195,275 +1188,6 @@ void App::BuildGui() {
}
}
static std::string FormatTimelineTime(double time_in_s) {
if (time_in_s < 0.0) time_in_s = 0.0;
char buf[64];
if (time_in_s == 0.0) {
return "0.00s";
} else if (time_in_s < 1e-3) {
// Microseconds range.
const double t_us = time_in_s * 1e6;
if (t_us >= 100.0) {
std::snprintf(buf, sizeof(buf), "%.0f\xC2\xB5s", t_us);
} else if (t_us >= 10.0) {
std::snprintf(buf, sizeof(buf), "%.1f\xC2\xB5s", t_us);
} else {
std::snprintf(buf, sizeof(buf), "%.2f\xC2\xB5s", t_us);
}
} else if (time_in_s < 1.0) {
// Milliseconds range.
const double t_ms = time_in_s * 1e3;
if (t_ms >= 100.0) {
std::snprintf(buf, sizeof(buf), "%.0fms", t_ms);
} else if (t_ms >= 10.0) {
std::snprintf(buf, sizeof(buf), "%.1fms", t_ms);
} else {
std::snprintf(buf, sizeof(buf), "%.2fms", t_ms);
}
} else {
// Seconds range.
if (time_in_s >= 100.0) {
std::snprintf(buf, sizeof(buf), "%.0fs", time_in_s);
} else if (time_in_s >= 10.0) {
std::snprintf(buf, sizeof(buf), "%.1fs", time_in_s);
} else {
std::snprintf(buf, sizeof(buf), "%.2fs", time_in_s);
}
}
return buf;
}
void App::TimelineScrubberGui() {
// Timeline scrubber: spine + sliding knob widget.
const double max_time = tmp_.sim_head_time_;
const int hist_size = sim_history_.Size();
const int hist_min = 1 - hist_size; // most negative index (oldest)
const int hist_max = 0; // index 0 = most recent
int current_index = sim_history_.GetIndex();
const bool locked = (hist_size <= 1);
// Compute current timestamp for LH label.
double curr_time =
(has_data() && current_index == sim_history_.GetIndex())
? data()->time
: (max_time +
current_index *
(has_model() ? model()->opt.timestep : 0.002));
if (curr_time < 0.0) curr_time = 0.0;
const int curr_step = (has_model() && model()->opt.timestep > 0)
? static_cast<int>(std::round(
curr_time / model()->opt.timestep))
: 0;
const int max_step =
(has_model() && model()->opt.timestep > 0)
? static_cast<int>(std::round(max_time / model()->opt.timestep))
: 0;
// Both LH and RH labels aim to be at 3 digits, switching units
// independently.
std::string lh_top_str = FormatTimelineTime(curr_time);
std::string rh_top_str = FormatTimelineTime(max_time);
std::string lh_bot_str = "(" + std::to_string(curr_step) + ")";
std::string rh_bot_str = "(" + std::to_string(max_step) + ")";
const char* lh_top_label = lh_top_str.c_str();
const char* rh_top_label = rh_top_str.c_str();
const char* lh_bot_label = lh_bot_str.c_str();
const char* rh_bot_label = rh_bot_str.c_str();
// Use slightly smaller text for timeline labels (e.g. 90% scale).
ImGui::SetWindowFontScale(0.9f);
const ImVec2 cursor = ImGui::GetCursorScreenPos();
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float base_label_w =
std::max(ImGui::CalcTextSize("88.8 ms").x,
ImGui::CalcTextSize("88.8 \xC2\xB5s").x);
const float curr_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (curr_lh_w > tmp_.timeline_lh_width) {
tmp_.timeline_lh_width = curr_lh_w;
}
const float curr_rh_w =
std::max({base_label_w, ImGui::CalcTextSize(rh_top_label).x,
ImGui::CalcTextSize(rh_bot_label).x});
if (curr_rh_w > tmp_.timeline_rh_width) {
tmp_.timeline_rh_width = curr_rh_w;
}
const float lh_box_w = tmp_.timeline_lh_width;
const float rh_box_w = tmp_.timeline_rh_width;
const float track_w =
std::max(10.0f, ImGui::GetContentRegionAvail().x - lh_box_w -
rh_box_w - spacing * 2.0f);
const float spine_h = 3.0f;
const float knob_w = 12.0f;
const float knob_h = 27.0f; // 1.5x taller than 18.0f
const float text_line_h = ImGui::GetTextLineHeight();
const float custom_line_spacing =
1.0f; // Decreased spacing between the two lines
const float text_2lines_h = text_line_h * 2.0f + custom_line_spacing;
const float total_h = std::max(knob_h, text_2lines_h);
// Vertical center of the track row.
const float row_center_y = cursor.y + total_h * 0.5f;
const float track_x0 = cursor.x + lh_box_w + spacing;
const float spine_y0 = row_center_y - spine_h * 0.5f;
const float spine_y1 = row_center_y + spine_h * 0.5f;
const float spine_x0 = track_x0;
const float spine_x1 = track_x0 + track_w;
// Compute knob position [0,1] along the spine.
float t = 1.0f; // Default: pinned to right end.
if (!locked) {
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
}
const float knob_cx = spine_x0 + t * (track_w - knob_w) + knob_w * 0.5f;
// Invisible interaction button over the full track area.
ImGui::SetCursorScreenPos(
ImVec2(track_x0, row_center_y - total_h * 0.5f));
ImGui::InvisibleButton("##Scrubber", ImVec2(track_w, total_h));
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
// Handle dragging.
if (!locked && (active || (hovered && ImGui::IsMouseDown(
ImGuiMouseButton_Left)))) {
const float mouse_x = ImGui::GetIO().MousePos.x;
if (!tmp_.scrubber_active) {
tmp_.scrubber_active = true;
if (std::abs(mouse_x - knob_cx) <= knob_w) {
tmp_.scrubber_grab_offset = mouse_x - knob_cx;
} else {
tmp_.scrubber_grab_offset = 0.0f;
}
}
const float effective_mouse_x = mouse_x - tmp_.scrubber_grab_offset;
const float raw_t = (effective_mouse_x - spine_x0 - knob_w * 0.5f) /
std::max(1.0f, track_w - knob_w);
const float clamped_t = std::clamp(raw_t, 0.0f, 1.0f);
int new_index = current_index;
const float snap_eps = std::max(
0.02f, std::min(0.05f, 8.0f / std::max(1.0f, track_w - knob_w)));
if (clamped_t <= snap_eps ||
effective_mouse_x <= spine_x0 + knob_w * 0.5f + 6.0f) {
new_index = hist_min;
} else if (clamped_t >= 1.0f - snap_eps ||
effective_mouse_x >= spine_x1 - knob_w * 0.5f - 6.0f) {
new_index = hist_max;
} else {
const float inner_t =
(clamped_t - snap_eps) / (1.0f - 2.0f * snap_eps);
new_index =
hist_min +
static_cast<int>(std::round(inner_t * (hist_max - hist_min)));
}
if (new_index != current_index) {
LoadHistory(new_index);
current_index = new_index;
t = static_cast<float>(current_index - hist_min) /
static_cast<float>(hist_max - hist_min);
if (has_data() && current_index == sim_history_.GetIndex()) {
curr_time = data()->time;
if (curr_time < 0.0) curr_time = 0.0;
lh_top_str = FormatTimelineTime(curr_time);
lh_top_label = lh_top_str.c_str();
const int updated_curr_step =
(has_model() && model()->opt.timestep > 0)
? static_cast<int>(
std::round(curr_time / model()->opt.timestep))
: 0;
lh_bot_str = "(" + std::to_string(updated_curr_step) + ")";
lh_bot_label = lh_bot_str.c_str();
const float updated_lh_w =
std::max({base_label_w, ImGui::CalcTextSize(lh_top_label).x,
ImGui::CalcTextSize(lh_bot_label).x});
if (updated_lh_w > tmp_.timeline_lh_width) {
tmp_.timeline_lh_width = updated_lh_w;
}
}
}
} else {
tmp_.scrubber_active = false;
}
// Draw LH labels (two lines, both right-aligned).
const float text_y0 = row_center_y - text_2lines_h * 0.5f;
const float text_y1 = text_y0 + text_line_h + custom_line_spacing;
const float lh_top_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_top_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_top_x, text_y0));
ImGui::TextUnformatted(lh_top_label);
const float lh_bot_x =
cursor.x + lh_box_w - ImGui::CalcTextSize(lh_bot_label).x;
ImGui::SetCursorScreenPos(ImVec2(lh_bot_x, text_y1));
ImGui::TextUnformatted(lh_bot_label);
// Draw RH labels (two lines, both left-aligned).
const float rh_x0 = track_x0 + track_w + spacing;
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y0));
ImGui::TextUnformatted(rh_top_label);
ImGui::SetCursorScreenPos(ImVec2(rh_x0, text_y1));
ImGui::TextUnformatted(rh_bot_label);
// Restore window font scale.
ImGui::SetWindowFontScale(1.0f);
// Draw spine and knob on the draw list.
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImGuiStyle& style = ImGui::GetStyle();
// Spine.
const ImVec4 scrollbar_bg =
ImGui::GetStyle().Colors[ImGuiCol_ScrollbarBg];
ImGuiCol bg_col_idx;
if (active) {
bg_col_idx = ImGuiCol_FrameBgActive;
} else if (hovered) {
bg_col_idx = ImGuiCol_FrameBgHovered;
} else {
bg_col_idx = (scrollbar_bg.w > 0.01f) ? ImGuiCol_ScrollbarBg
: ImGuiCol_FrameBg;
}
const ImU32 spine_col = ImGui::GetColorU32(bg_col_idx);
dl->AddRectFilled(ImVec2(spine_x0, spine_y0),
ImVec2(spine_x1, spine_y1), spine_col,
spine_h * 0.5f);
// Knob rectangle.
const float knob_x0 = knob_cx - knob_w * 0.5f;
const float knob_x1 = knob_cx + knob_w * 0.5f;
const float knob_y0 = row_center_y - knob_h * 0.5f;
const float knob_y1 = row_center_y + knob_h * 0.5f;
ImU32 knob_col;
if (active) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrabActive);
} else if (hovered) {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
} else {
knob_col = ImGui::GetColorU32(ImGuiCol_SliderGrab);
}
dl->AddRectFilled(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
knob_col, style.GrabRounding);
// Knob border.
dl->AddRect(ImVec2(knob_x0, knob_y0), ImVec2(knob_x1, knob_y1),
ImGui::GetColorU32(ImGuiCol_Border), style.GrabRounding);
// Advance layout cursor past this row.
ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + total_h));
ImGui::Dummy(ImVec2(0.0f, 0.0f));
}
void App::ModelOptionsGui() {
const float min_width = platform::GetExpectedLabelWidth();
const ImGuiChildFlags child_flags =
@@ -1471,220 +1195,29 @@ void App::ModelOptionsGui() {
const ImGuiTreeNodeFlags node_flags =
ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed;
ImGui::BeginChild("SimulationGui", {0, 0}, child_flags);
if (platform::SectionHeader(
"Simulation", node_flags | ImGuiTreeNodeFlags_DefaultOpen, 0.65f)) {
ImGui::PushID("SimSection");
const float slider_w = -ImGui::CalcTextSize(" Keyframe").x -
ImGui::GetStyle().ItemInnerSpacing.x;
bool is_dark = ImGui::GetStyle().Colors[ImGuiCol_WindowBg].x < 0.5f;
const ImColor green =
is_dark ? ImColor(40, 125, 60, 255) : ImColor(40, 180, 40, 255);
const ImColor yellow =
is_dark ? ImColor(158, 115, 18, 255) : ImColor(255, 215, 0, 255);
// Reset / Reload / Align buttons.
{
char reset_label[32];
std::snprintf(reset_label, sizeof(reset_label), "%s Reset",
platform::ICON_FA_UNDO);
char reload_label[32];
std::snprintf(reload_label, sizeof(reload_label), "%s Reload",
platform::ICON_FA_REFRESH);
char align_label[32];
std::snprintf(align_label, sizeof(align_label), "%s Align",
platform::ICON_FA_CROSSHAIRS);
const float avail = ImGui::GetContentRegionAvail().x;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float btn_w = (avail - spacing * 2) / 3.0f;
if (ImGui::Button(reset_label, ImVec2(btn_w, 0))) {
ResetPhysics();
}
ImGui::SameLine();
if (ImGui::Button(reload_label, ImVec2(btn_w, 0))) {
RequestModelReload();
}
ImGui::SameLine();
if (ImGui::Button(align_label, ImVec2(btn_w, 0))) {
const int cam_id = model()->vis.global.cameraid;
if (cam_id >= 0 && cam_id < model()->ncam) {
ui_.camera_idx = platform::SetCamera(model(), &camera_, cam_id);
} else {
mjv_defaultFreeCamera(model(), &camera_);
}
}
}
// Pause / Run toggle.
{
ImGui::Spacing();
char pause_label[32];
std::snprintf(pause_label, sizeof(pause_label), "%s Pause",
platform::ICON_FA_PAUSE);
char run_label[32];
std::snprintf(run_label, sizeof(run_label), "%s Run",
platform::ICON_FA_PLAY);
bool paused = step_control_.GetPauseState() != PauseState::kUnpaused;
bool running = step_control_.GetPauseState() == PauseState::kUnpaused;
const float avail = ImGui::GetContentRegionAvail().x;
const float half = avail * 0.5f;
const float h = ImGui::GetFrameHeight() * 1.4f;
ImGui::SetWindowFontScale(1.3f);
if (platform::ImGui_ColorButtonEx(pause_label, paused, yellow,
ImDrawFlags_RoundCornersLeft,
ImVec2(half, h))) {
step_control_.SetPauseState(PauseState::kNormalPaused);
}
ImGui::SameLine(0.f, 0.f);
if (platform::ImGui_ColorButtonEx(run_label, running, green,
ImDrawFlags_RoundCornersRight,
ImVec2(half, h))) {
step_control_.SetPauseState(PauseState::kUnpaused);
}
ImGui::SetWindowFontScale(1.0f);
}
// Speed slider.
{
const int max_idx = platform::kPercentRealTime.size() - 1;
int slider_val = max_idx - tmp_.speed_index;
float speed_pct = std::stof(platform::kPercentRealTime[tmp_.speed_index]);
char fmt[64];
const float desired = step_control_.GetSpeed();
const float measured = step_control_.GetSpeedMeasured();
bool misaligned = std::abs(measured - desired) > 0.1f * desired;
if (misaligned) {
std::snprintf(fmt, sizeof(fmt), "%.1f%%%% (%.1f%%%%)", speed_pct,
measured);
} else {
std::snprintf(fmt, sizeof(fmt), "%.1f%%%%", speed_pct);
}
ImGui::SetNextItemWidth(slider_w);
if (ImGui::SliderInt("Speed", &slider_val, 0, max_idx, fmt)) {
SetSpeedIndex(max_idx - slider_val);
}
if (misaligned) {
ImGui::SetItemTooltip("%s", "Desired Speed (Measured Speed)");
} else {
ImGui::SetItemTooltip("%s", "Percent of real-time");
}
}
// History controls (Frame Scrubber).
{
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
char prev_label[32];
std::snprintf(prev_label, sizeof(prev_label), "%s Step Back",
ICON_PREV_FRAME);
char next_label[32];
std::snprintf(next_label, sizeof(next_label), "%s Step Fwd",
ICON_NEXT_FRAME);
const float avail = ImGui::GetContentRegionAvail().x;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float btn_w = (avail - spacing) / 2.0f;
if (ImGui::Button(prev_label, ImVec2(btn_w, 0))) {
LoadHistory(sim_history_.GetIndex() - 1);
}
ImGui::SetItemTooltip("%s", "Load previous frame from history");
ImGui::SameLine();
if (ImGui::Button(next_label, ImVec2(btn_w, 0))) {
if (sim_history_.GetIndex() == 0) {
step_control_.RequestSingleStep();
} else {
LoadHistory(sim_history_.GetIndex() + 1);
}
}
ImGui::SetItemTooltip("%s", "Load next frame from history / Single step");
// Timeline scrubber.
TimelineScrubberGui();
}
// Keyframe controls.
if (model()->nkey > 0) {
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
{
char key_fmt[128];
const char* key_name = mj_id2name(model(), mjOBJ_KEY, ui_.key_idx);
if (key_name) {
std::snprintf(key_fmt, sizeof(key_fmt), "%s", key_name);
} else {
std::snprintf(key_fmt, sizeof(key_fmt), "Key %d", ui_.key_idx);
}
ImGui::SetNextItemWidth(slider_w);
ImGui::SliderInt("Keyframe", &ui_.key_idx, 0, model()->nkey - 1,
key_fmt);
}
// Keyframe buttons.
{
char load_label[32];
std::snprintf(load_label, sizeof(load_label), "%s Load key",
platform::ICON_FA_DOWNLOAD);
char save_label[32];
std::snprintf(save_label, sizeof(save_label), "%s Save key",
platform::ICON_FA_UPLOAD);
char copy_label[32];
std::snprintf(copy_label, sizeof(copy_label), "%s Copy key",
platform::ICON_FA_COPY);
const float avail = ImGui::GetContentRegionAvail().x;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float btn_w = (avail - spacing * 2) / 3.0f;
if (ImGui::Button(load_label, ImVec2(btn_w, 0))) {
mj_resetDataKeyframe(model(), data(), ui_.key_idx);
mj_forward(model(), data());
}
ImGui::SetItemTooltip("%s", "Load selected keyframe to active state");
ImGui::SameLine();
if (ImGui::Button(save_label, ImVec2(btn_w, 0))) {
mj_setKeyframe(model(), data(), ui_.key_idx);
}
ImGui::SetItemTooltip("%s", "Save active state to selected keyframe");
ImGui::SameLine();
if (ImGui::Button(copy_label, ImVec2(btn_w, 0))) {
std::string str = platform::KeyframeToString(model(), data(), false);
platform::MaybeSaveToClipboard(str);
}
ImGui::SetItemTooltip(
"%s", "Copy selected keyframe to clipboard as MJCF XML");
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Thread control.
ImGui::SetNextItemWidth(slider_w);
ImGui::BeginDisabled(std::thread::hardware_concurrency() <= 1);
if (ImGui::SliderInt("Threads", &ui_.nthread, 0, 8, "%d worker threads")) {
tmp_.update_threadpool = true;
}
ImGui::EndDisabled();
ImGui::SetItemTooltip("%s", "Number of worker threads in threadpool");
ImGui::Spacing();
ImGui::PopID();
ImGui::TreePop();
}
ImGui::EndChild();
const platform::SimulationGuiContext sim_ctx = {
.model = model(),
.data = data(),
.step_control = &step_control_,
.history = &sim_history_,
.timeline = &timeline_,
.speed_index = &tmp_.speed_index,
.key_idx = &ui_.key_idx,
.nthread = &ui_.nthread,
.update_threadpool = &tmp_.update_threadpool,
.reset = [this] { ResetPhysics(); },
.reload = [this] { RequestModelReload(); },
.align =
[this] {
const int cam_id = model()->vis.global.cameraid;
if (cam_id >= 0 && cam_id < model()->ncam) {
ui_.camera_idx = platform::SetCamera(model(), &camera_, cam_id);
} else {
mjv_defaultFreeCamera(model(), &camera_);
}
},
};
platform::SimulationGui(sim_ctx);
ImGui::BeginChild("PhysicsGui", {0, 0}, child_flags);
if (platform::SectionHeader("Physics", node_flags, 0.65f)) {
@@ -2243,7 +1776,8 @@ void App::ToolBarGui() {
platform::StepControlGui(&step_control_, tmp_.speed_index);
ImGui::SameLine(0, separator_width);
TimelineScrubberGui();
platform::TimelineScrubberGui(model(), data(), step_control_,
sim_history_, timeline_);
ImGui::TableNextColumn();
+1 -8
View File
@@ -164,13 +164,6 @@ class App {
int state_sig = 0;
std::vector<mjtNum> state;
// Timeline: simulation time recorded at history index 0 (the head).
double sim_head_time_ = 0.0;
float timeline_lh_width = 0.0f;
float timeline_rh_width = 0.0f;
bool scrubber_active = false;
float scrubber_grab_offset = 0.0f;
// Picture-in-Picture.
std::vector<platform::PipState> pips;
@@ -230,7 +223,6 @@ class App {
void MainMenuGui();
void ToolBarGui();
void TimelineScrubberGui();
void StatusBarGui();
void HelpGui();
void FileDialogGui();
@@ -269,6 +261,7 @@ class App {
platform::StepControl step_control_;
platform::SimProfiler profiler_;
platform::SimHistory sim_history_;
platform::SimulationTimelineState timeline_;
platform::SpecEditor spec_editor_;
std::vector<std::string> search_paths_;
std::vector<std::byte> pixels_;