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 <tuple>
|
||||||
|
|
||||||
#include <mujoco/mujoco.h>
|
#include <mujoco/mujoco.h>
|
||||||
|
#include <mujoco/experimental/platform/sim/sim_history.h>
|
||||||
#include <mujoco/experimental/platform/sim/step_control.h>
|
#include <mujoco/experimental/platform/sim/step_control.h>
|
||||||
#include "structs.h"
|
#include "structs.h"
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
@@ -82,4 +83,19 @@ PYBIND11_MODULE(sim, m, pybind11::mod_gil_not_used()) {
|
|||||||
.def("set_noise_parameters", &StepControl::SetNoiseParameters,
|
.def("set_noise_parameters", &StepControl::SetNoiseParameters,
|
||||||
py::arg("noise_scale"), py::arg("noise_rate"),
|
py::arg("noise_scale"), py::arg("noise_rate"),
|
||||||
"Sets the noise parameters.");
|
"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.model_path = model_path
|
||||||
self.step_control = sim.StepControl()
|
self.step_control = sim.StepControl()
|
||||||
self.ux_state = ux.UxState()
|
self.ux_state = ux.UxState()
|
||||||
|
self._setup_history()
|
||||||
self.status = f'Loaded: {os.path.basename(model_path)!r}'
|
self.status = f'Loaded: {os.path.basename(model_path)!r}'
|
||||||
|
|
||||||
return model, data
|
return model, data
|
||||||
@@ -117,6 +118,8 @@ class StudioApp:
|
|||||||
|
|
||||||
self.step_control = sim.StepControl()
|
self.step_control = sim.StepControl()
|
||||||
self.ux_state = ux.UxState()
|
self.ux_state = ux.UxState()
|
||||||
|
self.sim_history = sim.SimHistory()
|
||||||
|
self._setup_history()
|
||||||
self.theme = ux.GuiTheme.LIGHT
|
self.theme = ux.GuiTheme.LIGHT
|
||||||
self.show_info = False
|
self.show_info = False
|
||||||
self.show_solver = False
|
self.show_solver = False
|
||||||
@@ -209,6 +212,27 @@ class StudioApp:
|
|||||||
"""Reset the physics."""
|
"""Reset the physics."""
|
||||||
mujoco.mj_resetData(self.model, self.data)
|
mujoco.mj_resetData(self.model, self.data)
|
||||||
mujoco.mj_forward(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:
|
def apply_perturb(self, perturb: mujoco.MjvPerturb) -> None:
|
||||||
"""Apply perturbation the model."""
|
"""Apply perturbation the model."""
|
||||||
@@ -236,6 +260,10 @@ class StudioApp:
|
|||||||
called instead of ``step_control.advance``. The function receives
|
called instead of ``step_control.advance``. The function receives
|
||||||
``(model, data)`` and should step the simulation in-place.
|
``(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)
|
self.apply_perturb(perturb)
|
||||||
|
|
||||||
if step_fn is not None:
|
if step_fn is not None:
|
||||||
@@ -389,6 +417,17 @@ class StudioApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
imgui.Begin('Options')
|
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):
|
if imgui.TreeNodeEx('Physics Settings', node_flags):
|
||||||
ux.physics_gui(self.model)
|
ux.physics_gui(self.model)
|
||||||
imgui.TreePop()
|
imgui.TreePop()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
// Python bindings for MuJoCo platform UX components.
|
// Python bindings for MuJoCo platform UX components.
|
||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <tuple>
|
#include <tuple>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
#include <implot.h>
|
#include <implot.h>
|
||||||
#include <mujoco/mujoco.h>
|
#include <mujoco/mujoco.h>
|
||||||
#include <mujoco/experimental/platform/helpers.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/sim/step_control.h>
|
||||||
#include <mujoco/experimental/platform/ux/gui.h>
|
#include <mujoco/experimental/platform/ux/gui.h>
|
||||||
#include <mujoco/experimental/platform/ux/interaction.h>
|
#include <mujoco/experimental/platform/ux/interaction.h>
|
||||||
@@ -46,6 +48,12 @@ struct UxState {
|
|||||||
|
|
||||||
// Read/edited by camera_selection_gui
|
// Read/edited by camera_selection_gui
|
||||||
int camera_index = mujoco::platform::kTumbleCameraIdx;
|
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 {
|
struct RenderFlags {
|
||||||
@@ -72,6 +80,9 @@ PYBIND11_MODULE(ux, m, pybind11::mod_gil_not_used()) {
|
|||||||
.def_readwrite("state_sig", &UxState::state_sig)
|
.def_readwrite("state_sig", &UxState::state_sig)
|
||||||
.def_readwrite("watch_field_index", &UxState::watch_field_index)
|
.def_readwrite("watch_field_index", &UxState::watch_field_index)
|
||||||
.def_readwrite("camera_index", &UxState::camera_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(
|
.def_property(
|
||||||
"watch_field_name",
|
"watch_field_name",
|
||||||
[](const UxState& self) {
|
[](const UxState& self) {
|
||||||
@@ -128,6 +139,74 @@ PYBIND11_MODULE(ux, m, pybind11::mod_gil_not_used()) {
|
|||||||
"Render the simulation stepping control GUI. Modifies "
|
"Render the simulation stepping control GUI. Modifies "
|
||||||
"ux_state.speed_index.");
|
"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(
|
m.def(
|
||||||
"theme_select_gui",
|
"theme_select_gui",
|
||||||
[](mujoco::platform::GuiTheme theme) {
|
[](mujoco::platform::GuiTheme theme) {
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
#include <implot.h>
|
#include <implot.h>
|
||||||
#include <mujoco/mujoco.h>
|
#include <mujoco/mujoco.h>
|
||||||
#include "experimental/platform/helpers.h"
|
#include "experimental/platform/helpers.h"
|
||||||
|
#include "experimental/platform/sim/sim_history.h"
|
||||||
#include "experimental/platform/sim/sim_profiler.h"
|
#include "experimental/platform/sim/sim_profiler.h"
|
||||||
#include "experimental/platform/sim/step_control.h"
|
#include "experimental/platform/sim/step_control.h"
|
||||||
#include "experimental/platform/ux/imgui_widgets.h"
|
#include "experimental/platform/ux/imgui_widgets.h"
|
||||||
@@ -606,6 +609,506 @@ void SetSpeedIndex(StepControl* step_control, int& speed_index,
|
|||||||
step_control->SetSpeed(speed);
|
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) {
|
bool ThemeSelectGui(GuiTheme* theme, const ImVec2& size) {
|
||||||
static constexpr const char* ICON_DARKMODE = ICON_FA_CIRCLE;
|
static constexpr const char* ICON_DARKMODE = ICON_FA_CIRCLE;
|
||||||
static constexpr const char* ICON_LIGHTMODE = ICON_FA_CIRCLE_O;
|
static constexpr const char* ICON_LIGHTMODE = ICON_FA_CIRCLE_O;
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
// mjvOption, etc. But, some functions take additional arguments as needed.
|
// mjvOption, etc. But, some functions take additional arguments as needed.
|
||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <mujoco/mujoco.h>
|
#include <mujoco/mujoco.h>
|
||||||
|
#include "experimental/platform/sim/sim_history.h"
|
||||||
#include "experimental/platform/sim/sim_profiler.h"
|
#include "experimental/platform/sim/sim_profiler.h"
|
||||||
#include "experimental/platform/sim/step_control.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,
|
void SetSpeedIndex(StepControl* step_control, int& speed_index,
|
||||||
int request_idx);
|
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.
|
// UX for selecting the GUI theme.
|
||||||
bool ThemeSelectGui(GuiTheme* theme, const ImVec2& size = ImVec2(0, 0));
|
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);
|
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tmp_.sim_head_time_ = has_data() ? data()->time : 0.0;
|
timeline_.sim_head_time = has_data() ? data()->time : 0.0;
|
||||||
tmp_.timeline_lh_width = 0.0f;
|
timeline_.lh_width = 0.0f;
|
||||||
tmp_.timeline_rh_width = 0.0f;
|
timeline_.rh_width = 0.0f;
|
||||||
tmp_.scrubber_active = false;
|
timeline_.scrubber_active = false;
|
||||||
tmp_.scrubber_grab_offset = 0.0f;
|
timeline_.scrubber_grab_offset = 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::RequestModelLoad(std::string model_file) {
|
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);
|
mj_getState(model, data(), state.data(), mjSTATE_INTEGRATION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tmp_.sim_head_time_ = has_data() ? data()->time : 0.0;
|
timeline_.sim_head_time = has_data() ? data()->time : 0.0;
|
||||||
tmp_.timeline_lh_width = 0.0f;
|
timeline_.lh_width = 0.0f;
|
||||||
tmp_.timeline_rh_width = 0.0f;
|
timeline_.rh_width = 0.0f;
|
||||||
tmp_.scrubber_active = false;
|
timeline_.scrubber_active = false;
|
||||||
tmp_.scrubber_grab_offset = 0.0f;
|
timeline_.scrubber_grab_offset = 0.0f;
|
||||||
|
|
||||||
if (!preserve_camera_on_load_) {
|
if (!preserve_camera_on_load_) {
|
||||||
const int model_cam = model->vis.global.cameraid;
|
const int model_cam = model->vis.global.cameraid;
|
||||||
@@ -323,11 +323,11 @@ void App::ResetPhysics() {
|
|||||||
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
|
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tmp_.sim_head_time_ = has_data() ? data()->time : 0.0;
|
timeline_.sim_head_time = has_data() ? data()->time : 0.0;
|
||||||
tmp_.timeline_lh_width = 0.0f;
|
timeline_.lh_width = 0.0f;
|
||||||
tmp_.timeline_rh_width = 0.0f;
|
timeline_.rh_width = 0.0f;
|
||||||
tmp_.scrubber_active = false;
|
timeline_.scrubber_active = false;
|
||||||
tmp_.scrubber_grab_offset = 0.0f;
|
timeline_.scrubber_grab_offset = 0.0f;
|
||||||
step_error_ = "";
|
step_error_ = "";
|
||||||
edit_error_ = "";
|
edit_error_ = "";
|
||||||
}
|
}
|
||||||
@@ -394,7 +394,7 @@ void App::UpdatePhysics() {
|
|||||||
std::span<mjtNum> state = sim_history_.AddToHistory();
|
std::span<mjtNum> state = sim_history_.AddToHistory();
|
||||||
if (!state.empty()) {
|
if (!state.empty()) {
|
||||||
mj_getState(model(), data(), state.data(), mjSTATE_INTEGRATION);
|
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();
|
std::span<mjtNum> state = sim_history_.AddToHistory();
|
||||||
if (!state.empty()) {
|
if (!state.empty()) {
|
||||||
mj_getState(m, d, state.data(), mjSTATE_INTEGRATION);
|
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) {
|
void App::LoadHistory(int offset) {
|
||||||
std::span<mjtNum> state = sim_history_.SetIndex(offset);
|
platform::LoadHistoryFrame(sim_history_, step_control_, model(), data(),
|
||||||
if (!state.empty()) {
|
offset);
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool App::Update() {
|
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() {
|
void App::ModelOptionsGui() {
|
||||||
const float min_width = platform::GetExpectedLabelWidth();
|
const float min_width = platform::GetExpectedLabelWidth();
|
||||||
const ImGuiChildFlags child_flags =
|
const ImGuiChildFlags child_flags =
|
||||||
@@ -1471,220 +1195,29 @@ void App::ModelOptionsGui() {
|
|||||||
const ImGuiTreeNodeFlags node_flags =
|
const ImGuiTreeNodeFlags node_flags =
|
||||||
ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed;
|
ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_Framed;
|
||||||
|
|
||||||
ImGui::BeginChild("SimulationGui", {0, 0}, child_flags);
|
const platform::SimulationGuiContext sim_ctx = {
|
||||||
if (platform::SectionHeader(
|
.model = model(),
|
||||||
"Simulation", node_flags | ImGuiTreeNodeFlags_DefaultOpen, 0.65f)) {
|
.data = data(),
|
||||||
ImGui::PushID("SimSection");
|
.step_control = &step_control_,
|
||||||
|
.history = &sim_history_,
|
||||||
const float slider_w = -ImGui::CalcTextSize(" Keyframe").x -
|
.timeline = &timeline_,
|
||||||
ImGui::GetStyle().ItemInnerSpacing.x;
|
.speed_index = &tmp_.speed_index,
|
||||||
|
.key_idx = &ui_.key_idx,
|
||||||
bool is_dark = ImGui::GetStyle().Colors[ImGuiCol_WindowBg].x < 0.5f;
|
.nthread = &ui_.nthread,
|
||||||
const ImColor green =
|
.update_threadpool = &tmp_.update_threadpool,
|
||||||
is_dark ? ImColor(40, 125, 60, 255) : ImColor(40, 180, 40, 255);
|
.reset = [this] { ResetPhysics(); },
|
||||||
const ImColor yellow =
|
.reload = [this] { RequestModelReload(); },
|
||||||
is_dark ? ImColor(158, 115, 18, 255) : ImColor(255, 215, 0, 255);
|
.align =
|
||||||
|
[this] {
|
||||||
// Reset / Reload / Align buttons.
|
const int cam_id = model()->vis.global.cameraid;
|
||||||
{
|
if (cam_id >= 0 && cam_id < model()->ncam) {
|
||||||
char reset_label[32];
|
ui_.camera_idx = platform::SetCamera(model(), &camera_, cam_id);
|
||||||
std::snprintf(reset_label, sizeof(reset_label), "%s Reset",
|
} else {
|
||||||
platform::ICON_FA_UNDO);
|
mjv_defaultFreeCamera(model(), &camera_);
|
||||||
char reload_label[32];
|
}
|
||||||
std::snprintf(reload_label, sizeof(reload_label), "%s Reload",
|
},
|
||||||
platform::ICON_FA_REFRESH);
|
};
|
||||||
char align_label[32];
|
platform::SimulationGui(sim_ctx);
|
||||||
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();
|
|
||||||
|
|
||||||
ImGui::BeginChild("PhysicsGui", {0, 0}, child_flags);
|
ImGui::BeginChild("PhysicsGui", {0, 0}, child_flags);
|
||||||
if (platform::SectionHeader("Physics", node_flags, 0.65f)) {
|
if (platform::SectionHeader("Physics", node_flags, 0.65f)) {
|
||||||
@@ -2243,7 +1776,8 @@ void App::ToolBarGui() {
|
|||||||
platform::StepControlGui(&step_control_, tmp_.speed_index);
|
platform::StepControlGui(&step_control_, tmp_.speed_index);
|
||||||
|
|
||||||
ImGui::SameLine(0, separator_width);
|
ImGui::SameLine(0, separator_width);
|
||||||
TimelineScrubberGui();
|
platform::TimelineScrubberGui(model(), data(), step_control_,
|
||||||
|
sim_history_, timeline_);
|
||||||
|
|
||||||
ImGui::TableNextColumn();
|
ImGui::TableNextColumn();
|
||||||
|
|
||||||
|
|||||||
@@ -164,13 +164,6 @@ class App {
|
|||||||
int state_sig = 0;
|
int state_sig = 0;
|
||||||
std::vector<mjtNum> state;
|
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.
|
// Picture-in-Picture.
|
||||||
std::vector<platform::PipState> pips;
|
std::vector<platform::PipState> pips;
|
||||||
|
|
||||||
@@ -230,7 +223,6 @@ class App {
|
|||||||
|
|
||||||
void MainMenuGui();
|
void MainMenuGui();
|
||||||
void ToolBarGui();
|
void ToolBarGui();
|
||||||
void TimelineScrubberGui();
|
|
||||||
void StatusBarGui();
|
void StatusBarGui();
|
||||||
void HelpGui();
|
void HelpGui();
|
||||||
void FileDialogGui();
|
void FileDialogGui();
|
||||||
@@ -269,6 +261,7 @@ class App {
|
|||||||
platform::StepControl step_control_;
|
platform::StepControl step_control_;
|
||||||
platform::SimProfiler profiler_;
|
platform::SimProfiler profiler_;
|
||||||
platform::SimHistory sim_history_;
|
platform::SimHistory sim_history_;
|
||||||
|
platform::SimulationTimelineState timeline_;
|
||||||
platform::SpecEditor spec_editor_;
|
platform::SpecEditor spec_editor_;
|
||||||
std::vector<std::string> search_paths_;
|
std::vector<std::string> search_paths_;
|
||||||
std::vector<std::byte> pixels_;
|
std::vector<std::byte> pixels_;
|
||||||
|
|||||||
Reference in New Issue
Block a user