Move the Simulation panel into the platform toolkit.
PiperOrigin-RevId: 965914672 Change-Id: Ib349b77055c57453780818dd4d8f48899b7024d5
This commit is contained in:
committed by
Copybara-Service
parent
0fa066237a
commit
c232938233
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
Reference in New Issue
Block a user