diff --git a/python/mujoco/experimental/studio/sim.cc b/python/mujoco/experimental/studio/sim.cc index a840cf21..cc2c7ba1 100644 --- a/python/mujoco/experimental/studio/sim.cc +++ b/python/mujoco/experimental/studio/sim.cc @@ -17,6 +17,7 @@ #include #include +#include #include #include "structs.h" #include @@ -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_(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."); } diff --git a/python/mujoco/experimental/studio/studio_app.py b/python/mujoco/experimental/studio/studio_app.py index 042f4c6e..7b0e9ad0 100644 --- a/python/mujoco/experimental/studio/studio_app.py +++ b/python/mujoco/experimental/studio/studio_app.py @@ -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() diff --git a/python/mujoco/experimental/studio/ux.cc b/python/mujoco/experimental/studio/ux.cc index c6e30516..8804ae0d 100644 --- a/python/mujoco/experimental/studio/ux.cc +++ b/python/mujoco/experimental/studio/ux.cc @@ -15,6 +15,7 @@ // Python bindings for MuJoCo platform UX components. #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -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(model_obj).get(); + mjData* data = py::cast(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 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 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(model_obj).get(); + mjData* data = py::cast(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) { diff --git a/src/experimental/platform/ux/gui.cc b/src/experimental/platform/ux/gui.cc index 86d9cb62..6183c4cf 100644 --- a/src/experimental/platform/ux/gui.cc +++ b/src/experimental/platform/ux/gui.cc @@ -19,8 +19,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -28,6 +30,7 @@ #include #include #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 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(std::round( + curr_time / model->opt.timestep)) + : 0; + const int max_step = + ((model != nullptr) && model->opt.timestep > 0) + ? static_cast(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(current_index - hist_min) / + static_cast(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(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(current_index - hist_min) / + static_cast(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( + 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; diff --git a/src/experimental/platform/ux/gui.h b/src/experimental/platform/ux/gui.h index fe2a3a4c..a0f0540e 100644 --- a/src/experimental/platform/ux/gui.h +++ b/src/experimental/platform/ux/gui.h @@ -24,11 +24,13 @@ // mjvOption, etc. But, some functions take additional arguments as needed. #include +#include #include #include #include #include +#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 reset; // reset the physics state + std::function reload; // reload the model + std::function 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)); diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc index 4b92367c..4db7387a 100644 --- a/src/experimental/studio/app.cc +++ b/src/experimental/studio/app.cc @@ -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 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 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 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(std::round( - curr_time / model()->opt.timestep)) - : 0; - const int max_step = - (has_model() && model()->opt.timestep > 0) - ? static_cast(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(current_index - hist_min) / - static_cast(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(std::round(inner_t * (hist_max - hist_min))); - } - - if (new_index != current_index) { - LoadHistory(new_index); - current_index = new_index; - t = static_cast(current_index - hist_min) / - static_cast(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( - 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(); diff --git a/src/experimental/studio/app.h b/src/experimental/studio/app.h index c9d456d3..1da7f7bc 100644 --- a/src/experimental/studio/app.h +++ b/src/experimental/studio/app.h @@ -164,13 +164,6 @@ class App { int state_sig = 0; std::vector 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 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 search_paths_; std::vector pixels_;