diff --git a/Cargo.toml b/Cargo.toml index 4f497f6..b6e38d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,12 @@ all-features = true [features] viewer = ["dep:bitflags", "dep:glutin", "dep:winit", "dep:glutin-winit"] +viewer-ui = ["viewer", "dep:egui-winit", "dep:egui_glow", "dep:egui"] renderer = ["dep:bitflags", "dep:glutin", "dep:winit", "dep:glutin-winit", "dep:png"] cpp-viewer = [] ffi-regenerate = ["dep:regex", "dep:bindgen"] # Generate the ffi bindings. Only used for updating the committed ``mujoco_c`` module. -default = ["viewer", "renderer"] +default = ["viewer", "viewer-ui", "renderer"] [dependencies] bitflags = {version = "2.9.4", optional = true} @@ -34,6 +35,10 @@ paste = "1.0.15" glutin = {version = "0.32.3", optional = true} winit = {version = "0.30.12", optional = true} glutin-winit = {version = "0.5.0", optional = true} +# Viewer's user interface dependencies +egui-winit = {version = "0.33.0", default-features = false, features = ["wayland", "x11"], optional = true} +egui_glow = {version = "0.33.0", features = ["x11", "winit", "wayland", "egui-winit"], optional = true} +egui = {version = "0.33.0", optional = true} [build-dependencies] bindgen = {version = "0.71.1", optional = true} diff --git a/src/render_base.rs b/src/render_base.rs index 61a61fc..0854e46 100644 --- a/src/render_base.rs +++ b/src/render_base.rs @@ -100,8 +100,8 @@ impl ApplicationHandler for RenderBase { let raw_window_handle = Some(window.window_handle() .map(|x| x.as_raw()).unwrap()); let context_attrs = ContextAttributesBuilder::new() - .with_profile(GlProfile::Core) - .with_context_api(ContextApi::OpenGl(Some(Version::new(1, 5)))) + .with_profile(GlProfile::Compatibility) + .with_context_api(ContextApi::OpenGl(Some(Version::new(2, 0)))) .build(raw_window_handle); let gl_display = gl_config.display(); diff --git a/src/viewer.rs b/src/viewer.rs index d3082b9..a661383 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,5 +1,6 @@ //! Module related to implementation of the [`MjViewer`]. For implementation of the C++ wrapper, //! see [`crate::cpp_viewer::MjViewerCpp`]. +#[cfg(feature = "viewer-ui")] use glutin::display::GetGlDisplay; use glutin::prelude::PossiblyCurrentGlContext; use glutin::surface::GlSurface; @@ -26,6 +27,10 @@ use crate::wrappers::mj_data::MjData; use crate::get_mujoco_version; +#[cfg(feature = "viewer-ui")] +mod ui; + + /****************************************** */ // Rust native viewer /****************************************** */ @@ -135,17 +140,22 @@ pub struct MjViewer + Clone> { last_bnt_press_time: Instant, rect_view: MjrRectangle, rect_full: MjrRectangle, - status_flags: ViewerStatusBits, // Status flag indicating the state of the menu /* OpenGL */ adapter: RenderBase, event_loop: EventLoop<()>, modifiers: Modifiers, buttons_pressed: ButtonsPressed, - cursor_position: (u32, u32), + raw_cursor_position: (f64, f64), /* External interaction */ user_scene: MjvScene, + + /* User interface */ + #[cfg(feature = "viewer-ui")] + ui: ui::ViewerUI, + + status: ViewerStatusBit } impl + Clone> MjViewer { @@ -167,6 +177,7 @@ impl + Clone> MjViewer { let GlState { gl_context, gl_surface, + #[cfg(feature = "viewer-ui")] window, .. } = adapter.state.as_ref().unwrap(); gl_context.make_current(gl_surface).map_err(MjViewerError::GlutinError)?; @@ -181,6 +192,9 @@ impl + Clone> MjViewer { let context = MjrContext::new(&model); let camera = MjvCamera::new_free(&model); + #[cfg(feature = "viewer-ui")] + let ui = ui::ViewerUI::new(model.clone(), &window, &gl_surface.display()); + Ok(Self { model, scene, @@ -191,7 +205,6 @@ impl + Clone> MjViewer { user_scene, last_x: 0.0, last_y: 0.0, - status_flags: ViewerStatusBits::HELP_MENU, last_bnt_press_time: Instant::now(), rect_view: MjrRectangle::default(), rect_full: MjrRectangle::default(), @@ -199,7 +212,10 @@ impl + Clone> MjViewer { event_loop, modifiers: Modifiers::default(), buttons_pressed: ButtonsPressed::empty(), - cursor_position: (0, 0) + raw_cursor_position: (0.0, 0.0), + #[cfg(feature = "viewer-ui")] ui, + #[cfg(feature = "viewer-ui")] status: ViewerStatusBit::UI, + #[cfg(not(feature = "viewer-ui"))] status: ViewerStatusBit::HELP, }) } @@ -235,18 +251,27 @@ impl + Clone> MjViewer { pub fn sync(&mut self, data: &mut MjData) { let GlState { gl_context, - gl_surface, .. + gl_surface, + .. } = self.adapter.state.as_mut().unwrap(); /* Make sure everything is done on the viewer's window */ gl_context.make_current(gl_surface).expect("could not make OpenGL context current"); + /* Read the screen size */ + self.update_rectangles(self.adapter.state.as_ref().unwrap().window.inner_size().into()); + /* Process mouse and keyboard events */ self.process_events(data); + /* Update the scene from data and render */ self.update_scene(data); + /* Draw the user menu on top */ + #[cfg(feature = "viewer-ui")] + self.process_user_ui(data); + /* Update the user menu state and overlays */ self.update_menus(); @@ -265,7 +290,6 @@ impl + Clone> MjViewer { gl_surface, .. } = self.adapter.state.as_mut().unwrap(); - /* Make sure everything is done on the viewer's window */ gl_surface.swap_buffers(gl_context).expect("buffer swap in OpenGL failed"); } @@ -275,9 +299,6 @@ impl + Clone> MjViewer { let bound_model_ptr = unsafe { self.model.__raw() }; assert_eq!(model_data_ptr, bound_model_ptr, "'data' must be created from the same model as the viewer."); - /* Read the screen size */ - self.update_rectangles(self.adapter.state.as_ref().unwrap().window.inner_size().into()); - /* Update the scene from the MjData state */ self.scene.update(data, &self.opt, &self.pert, &mut self.camera); @@ -294,7 +315,7 @@ impl + Clone> MjViewer { rectangle.width = rectangle.width - rectangle.width / 4; /* Overlay section */ - if self.status_flags.contains(ViewerStatusBits::HELP_MENU) { // Help + if self.status.contains(ViewerStatusBit::HELP) { // Help self.context.overlay( MjtFont::mjFONT_NORMAL, MjtGridPos::mjGRID_TOPLEFT, rectangle, @@ -304,6 +325,47 @@ impl + Clone> MjViewer { } } + + /// Draws the user UI + #[cfg(feature = "viewer-ui")] + fn process_user_ui(&mut self, data: &mut MjData) { + /* Draw the user interface */ + + use crate::viewer::ui::UiEvent; + let GlState { window, .. } = &self.adapter.state.as_ref().unwrap(); + let inner_size = window.inner_size(); + let (left, is_covered) = self.ui.process( + window, &mut self.status, + self.scene.flags_mut(), &mut self.opt, + &mut self.camera, data + ); + + self.status.set(ViewerStatusBit::UI_COVERED, is_covered); + + /* Adjust the viewport so MuJoCo doesn't draw over the UI */ + self.rect_view.left = left as i32; + self.rect_view.width = inner_size.width as i32; + + /* Reset some OpenGL settings so that MuJoCo can still draw */ + self.ui.reset(); + + /* Process events made in the user UI */ + while let Some(event) = self.ui.drain_events() { + use UiEvent::*; + match event { + Close => self.adapter.running = false, + Fullscreen => self.toggle_full_screen(), + ResetSimulation => { + data.reset(); + data.forward(); + }, + AlignCamera => { + self.camera = MjvCamera::new_free(&self.model); + } + } + } + } + /// Updates the dimensions of the rectangles defining the dimensions of /// the user interface, as well as the actual scene viewer. fn update_rectangles(&mut self, viewport_size: (i32, i32)) { @@ -315,13 +377,35 @@ impl + Clone> MjViewer { self.rect_full.height = viewport_size.1; } + /// Checks whether the cursor is inside the user interface (i.e., outside of MuJoCo's scene). + #[cfg(feature = "viewer-ui")] + fn is_cursor_outside(&self, x: f64, y: f64) -> bool { + x < self.rect_view.left as f64 || y < self.rect_view.bottom as f64 || + self.status.contains(ViewerStatusBit::UI_COVERED) + } + /// Processes user input events. fn process_events(&mut self, data: &mut MjData) { self.event_loop.pump_app_events(Some(Duration::ZERO), &mut self.adapter); while let Some(window_event) = self.adapter.queue.pop_front() { + /* Draw the user interface */ + #[cfg(feature = "viewer-ui")] + { + let window: &winit::window::Window = &self.adapter.state.as_ref().unwrap().window; + self.ui.handle_events(window, &window_event); + } + match window_event { WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers, WindowEvent::MouseInput {state, button, .. } => { + let (x, y) = self.raw_cursor_position; + let is_pressed = state == ElementState::Pressed; + + #[cfg(feature = "viewer-ui")] + if self.is_cursor_outside(x, y) && is_pressed { + continue; + } + let index = match button { MouseButton::Left => { self.process_left_click(data, state); @@ -332,12 +416,19 @@ impl + Clone> MjViewer { _ => return }; - self.buttons_pressed.set(index, state == ElementState::Pressed); + self.buttons_pressed.set(index, is_pressed); } WindowEvent::CursorMoved { position, .. } => { - self.process_cursor_pos(position.x, position.y, data); - self.cursor_position = position.into(); + let PhysicalPosition { x, y } = position; + self.raw_cursor_position = position.into(); + + #[cfg(feature = "viewer-ui")] + if self.is_cursor_outside(x, y) { + continue; + } + + self.process_cursor_pos(x, y, data); } // Set the viewer's state to pending exit. @@ -367,7 +458,7 @@ impl + Clone> MjViewer { state: ElementState::Pressed, .. }, .. } => { - self.status_flags.toggle(ViewerStatusBits::HELP_MENU); + self.status.toggle(ViewerStatusBit::HELP); } // Full screen @@ -452,8 +543,21 @@ impl + Clone> MjViewer { .. } => self.toggle_opt_flag(MjtVisFlag::mjVIS_INERTIA), + #[cfg(feature = "viewer-ui")] + WindowEvent::KeyboardInput { + event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyX), state: ElementState::Pressed, ..}, + .. + } => self.status.toggle(ViewerStatusBit::UI), + // Zoom in/out WindowEvent::MouseWheel {delta, ..} => { + let (x, y) = self.raw_cursor_position; + + #[cfg(feature = "viewer-ui")] + if self.is_cursor_outside(x, y) { + continue; + } + let value = match delta { MouseScrollDelta::LineDelta(_, down) => down as f64, MouseScrollDelta::PixelDelta(PhysicalPosition {y, ..}) => y * TOUCH_BAR_ZOOM_FACTOR @@ -469,7 +573,8 @@ impl + Clone> MjViewer { /// Toggles visualization options. fn toggle_opt_flag(&mut self, flag: MjtVisFlag) { let index = flag as usize; - self.opt.flags[index] = !self.opt.flags[index]; + let val = self.opt.flags[index]; + self.opt.flags[index] = if val == 1 { 0 } else { 1 }; } /// Cycle MJCF defined cameras. @@ -558,12 +663,12 @@ impl + Clone> MjViewer { /* Double click detection */ if self.last_bnt_press_time.elapsed().as_millis() < DOUBLE_CLICK_WINDOW_MS { - let cp = self.cursor_position; - let x = cp.0 as f64; - let y = (self.rect_full.height as u32 - cp.1) as f64; + let cp = self.raw_cursor_position; + let x = cp.0; + let y = self.rect_full.height as f64 - cp.1; /* Obtain the selection */ - let rect = &self.rect_view; + let rect = &self.rect_full; let (body_id, _, flex_id, skin_id, xyz) = self.scene.find_selection( data, &self.opt, rect.width as MjtNum / rect.height as MjtNum, @@ -606,14 +711,14 @@ impl + Clone> MjViewer { } } - - bitflags! { - /// Boolean flags that define some of - /// the Viewer's internal state. #[derive(Debug)] - struct ViewerStatusBits: u8 { - const HELP_MENU = 1 << 0; + struct ViewerStatusBit: u8 { + const HELP = 1 << 0; + #[cfg(feature = "viewer-ui")] + const UI = 1 << 1; + #[cfg(feature = "viewer-ui")] + const UI_COVERED = 1 << 2; } } diff --git a/src/viewer/ui.rs b/src/viewer/ui.rs new file mode 100644 index 0000000..7f01b08 --- /dev/null +++ b/src/viewer/ui.rs @@ -0,0 +1,488 @@ +//! Implementation of the interface for use in the viewer. +use egui_glow::glow::{FILL, FRONT_AND_BACK, HasContext}; +use egui_winit::winit::event::WindowEvent; +use glutin::display::{Display, GlDisplay}; +use egui_winit::winit::window::Window; +use egui::{FontId, RichText}; +use egui_winit::egui; +use egui_winit; + +use std::collections::VecDeque; +use std::ffi::CString; +use std::fmt::Debug; +use std::ops::Deref; +use std::sync::Arc; + +use crate::wrappers::mj_visualization::{ + MjvOption, MjvCamera, MjtCamera +}; +use crate::wrappers::mj_model::{MjModel, MjtObj, MjtJoint}; +use crate::wrappers::mj_data::MjData; +use crate::viewer::ViewerStatusBit; + +const MAIN_FONT: FontId = FontId::proportional(15.0); +const HEADING_FONT: FontId = FontId::proportional(20.0); +const HEADING_POST_SPACE: f32 = 5.0; +const BUTTON_SPACING_X: f32 = 10.0; +const BUTTON_SPACING_Y: f32 = 5.0; +const BUTTON_ROUNDING: f32 = 50.0; + +const SIDE_PANEL_DEFAULT_WIDTH: f32 = 250.0; +const TOGGLE_LABEL_HEIGHT_EXTRA_SPACE: f32 = 20.0; +const SIDE_PANEL_PAD: f32 = 10.0; + +/// Maps [`MjtRndFlag`](crate::wrappers::mj_visualization::MjtRndFlag) to their string +const GL_EFFECT_MAP: [&str; 10] = [ + "Shadow", + "Wireframe", + "Reflection", + "Additive", + "Skybox", + "Fog", + "Haze", + "Segment", + "ID color", + "Cull face" +]; + +/// Maps [`MjtVisFlag`](crate::wrappers::mj_visualization::MjtVisFlag) to their string +const VIS_OPT_MAP: [&str; 31] = [ + "Convex hull", + "Texture", + "Joint", + "Camera", + "Actuator", + "Activation", + "Light", + "Tendon", + "Range finder", + "Constraint", + "Inertia", + "Scale inertia", + "Perturbation force", + "Perturbation object", + "Contact point", + "Island", + "Contact force", + "Contact split", + "Transparent", + "Auto-connect", + "Center of mass", + "Select", + "Static", + "Skin", + "Flex vertex", + "Flex edge", + "Flex face", + "Flex skin", + "Body BVH", + "Mesh BVH", + "SDF iteration" +]; + +/// Maps [`MjtLabel`](crate::wrappers::mj_visualization::MjtLabel) to their string +const LABEL_TYPE_MAP: [&str; 17] = [ + "None", + "Body", + "Joint", + "Geom", + "Site", + "Camera", + "Light", + "Tendon", + "Actuator", + "Constraint", + "Flex", + "Skin", + "Selection", + "Selection point", + "Contact point", + "Contact force", + "Island" +]; + +/// Maps [`MjtFrame`](crate::wrappers::mj_visualization::MjtFrame) to their string +const FRAME_TYPE_MAP: [&str; 8] = [ + "None", + "Body", + "Geom", + "Site", + "Camera", + "Light", + "Contact", + "World" +]; + +/// Viewer user interface context. +pub(crate) struct ViewerUI> { + egui_ctx: egui::Context, + state: egui_winit::State, + painter: egui_glow::Painter, + gl: Arc, + events: VecDeque, + camera_names: Vec, + actuator_names: Vec, + joint_names: Vec, + model: M +} + +impl> ViewerUI { + /// Create a new [`ViewerUI`] instance for the specific winit window. + pub(crate) fn new(model: M, window: &Window, display: &Display) -> Self { + let egui_ctx = egui::Context::default(); + let viewport_id = egui_ctx.viewport_id(); + + let state = egui_winit::State::new( + egui_ctx.clone(), viewport_id, &window, + None, None, None + ); + + let get_addr = |s: &str| display.get_proc_address( + &CString::new(s).unwrap() + ); + let gl = unsafe { Arc::new(egui_glow::glow::Context::from_loader_function(get_addr)) }; + + let camera_names = (0..model.ncam()).map(|i| { + if let Some(name) = model.id_to_name(MjtObj::mjOBJ_CAMERA, i) { + name.to_string() + } else { format!("Camera {i}") } + }).collect(); + + let actuator_names = (0..model.nu()).map(|i| { + if let Some(name) = model.id_to_name(MjtObj::mjOBJ_ACTUATOR, i) { + name.to_string() + } else { format!("Actuator {i}") } + }).collect(); + + let joint_names = (0..model.njnt()).filter_map(|i| { + match model.jnt_type()[i as usize] { + MjtJoint::mjJNT_SLIDE | MjtJoint::mjJNT_HINGE => { + Some(if let Some(name) = model.id_to_name(MjtObj::mjOBJ_JOINT, i) { + name.to_string() + } else { format!("Joint {i}") }) + } + _ => None + } + }).collect(); + + let painter = egui_glow::Painter::new( + gl.clone(), + "", + None, + false + ).unwrap(); + + Self { + egui_ctx, state, painter, gl, events: VecDeque::new(), + camera_names, actuator_names, model, joint_names + } + } + + /// Handles winit input events. + pub(crate) fn handle_events(&mut self, window: &Window, event: &WindowEvent) { + let _ = self.state.on_window_event(&window, event); // ignore response as it can be obtained later. + } + + /// Draws the UI to the viewport. + pub(crate) fn process( + &mut self, + window: &Window, status: &mut ViewerStatusBit, + scene_flags: &mut [u8], options: &mut MjvOption, + camera: &mut MjvCamera, + data: &mut MjData + ) -> (f32, bool) { + let raw_input = self.state.take_egui_input(&window); + + // Viewport reservations, which will be excluded from MuJoCo's viewport. + // This way MuJoCo won't draw over the UI. + let mut left = 0.0; + let mut covered = false; + let full_output = self.egui_ctx.run(raw_input, |ctx| { + if status.contains(ViewerStatusBit::UI) { + egui::SidePanel::new(egui::panel::Side::Left,"interface_panel") + .resizable(true) + .default_width(SIDE_PANEL_DEFAULT_WIDTH) + .show(ctx, |ui| + { + // The menu + egui::ScrollArea::vertical() + .max_height(ui.available_height() - (TOGGLE_LABEL_HEIGHT_EXTRA_SPACE + HEADING_POST_SPACE + HEADING_FONT.size)) + .show(ui, |ui| + { + // Make buttons have more space in the width + let spacing = ui.spacing_mut(); + spacing.button_padding.x = BUTTON_SPACING_X; + spacing.button_padding.y = BUTTON_SPACING_Y; + + /* Window controls */ + egui::CollapsingHeader::new(RichText::new("Window").font(HEADING_FONT)) + .default_open(true) + .show(ui, |ui| + { + if ui.add(egui::Button::new( + RichText::new("Quit").font(MAIN_FONT) + ).corner_radius(BUTTON_ROUNDING)).clicked() { + self.events.push_back(UiEvent::Close); + } + }); + + /* Option */ + egui::CollapsingHeader::new(RichText::new("Option").font(HEADING_FONT)) + .default_open(true) + .show(ui, |ui| + { + ui.horizontal_wrapped(|ui| { + let mut selected = status.contains(ViewerStatusBit::HELP); + ui.toggle_value(&mut selected, RichText::new("Help").font(MAIN_FONT)); + status.set(ViewerStatusBit::HELP, selected); + + selected = window.fullscreen().is_some(); + if ui.toggle_value(&mut selected, RichText::new("Fullscreen").font(MAIN_FONT)).clicked() { + self.events.push_back(UiEvent::Fullscreen); + }; + }); + }); + + /* Simulation */ + egui::CollapsingHeader::new(RichText::new("Simulation").font(HEADING_FONT)) + .default_open(true) + .show(ui, |ui| + { + ui.horizontal_wrapped(|ui| { + // Reset simulation + if ui.add(egui::Button::new( + RichText::new("Reset").font(MAIN_FONT) + ).corner_radius(BUTTON_ROUNDING)).clicked() { + self.events.push_back(UiEvent::ResetSimulation); + } + + // Align camera + if ui.add(egui::Button::new( + RichText::new("Align").font(MAIN_FONT) + ).corner_radius(BUTTON_ROUNDING)).clicked() { + self.events.push_back(UiEvent::AlignCamera); + } + }); + }); + + /* Visualization options */ + egui::CollapsingHeader::new(RichText::new("Rendering").font(HEADING_FONT)) + .default_open(false) + .show(ui, |ui| + { + egui::Grid::new("render_select_grid").show(ui, |ui| { + // Camera + ui.label(RichText::new("Camera").font(MAIN_FONT)); + let mut current_cam_name = match unsafe {std::mem::transmute(camera.type_) } { + MjtCamera::mjCAMERA_FIXED => &self.camera_names[camera.fixedcamid as usize], + MjtCamera::mjCAMERA_TRACKING => "Tracking", + MjtCamera::mjCAMERA_FREE => "Free", + MjtCamera::mjCAMERA_USER => "User", + }; + + ui.menu_button(current_cam_name, |ui| { + if ui.selectable_value(&mut current_cam_name, "Free", "Free").clicked() { + camera.free(); + } + + for (id, name) in self.camera_names.iter().enumerate() { + if ui.selectable_value(&mut current_cam_name, &name, name).clicked() { + *camera = MjvCamera::new_fixed(id as u32); + } + } + }); + ui.end_row(); + + // Label + ui.label(RichText::new("Label").font(MAIN_FONT)); + let mut current_lbl_ty = LABEL_TYPE_MAP[options.label as usize]; + ui.menu_button(current_lbl_ty, |ui| { + for (label_type_i, label_type) in LABEL_TYPE_MAP.iter().enumerate() { + if ui.selectable_value( + &mut current_lbl_ty, + label_type, *label_type + ).clicked() { + options.label = label_type_i as i32; + }; + } + + }); + ui.end_row(); + + // Frame + ui.label(RichText::new("Frame").font(MAIN_FONT)); + let mut current_frm_ty = FRAME_TYPE_MAP[options.frame as usize]; + ui.menu_button(current_frm_ty, |ui| { + for (frame_type_i, frame_type) in FRAME_TYPE_MAP.iter().enumerate() { + if ui.selectable_value( + &mut current_frm_ty, + frame_type, *frame_type + ).clicked() { + options.frame = frame_type_i as i32; + }; + } + + }); + ui.end_row(); + }); + + ui.collapsing("Elements", |ui| { + ui.horizontal_wrapped(|ui| { + for (flag, enabled) in &mut options.flags.iter_mut().enumerate() { + if ui.toggle_value(&mut (*enabled == 1), VIS_OPT_MAP[flag]).clicked() { + *enabled = if *enabled == 1 { 0 } else { 1 }; + } + } + }); + }); + + ui.collapsing("OpenGL effects", |ui| { + ui.horizontal_wrapped(|ui| { + for (flag, enabled) in scene_flags.iter_mut().enumerate() { + if ui.toggle_value(&mut (*enabled == 1), GL_EFFECT_MAP[flag]).clicked() { + *enabled = if *enabled == 1 { 0 } else { 1 }; + } + } + }); + }); + }); + }); + + // Panel toggle info + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + ui.add_space(HEADING_POST_SPACE); + ui.heading("Toggle: X"); + }); + + // Make the panel track its width on resize + ui.take_available_space(); + left = ui.max_rect().max.x + SIDE_PANEL_PAD; + }); + + // Controls window + egui::Window::new("Controls") + .default_open(false) + .fade_in(false) + .fade_out(false) + .scroll(true) + .show(ctx, |ui| + { + egui::Grid::new("ctrl_grid").show(ui, |ui| { + for (((actuator_name, ctrl), range), limited) in self.actuator_names.iter() + .zip(data.ctrl_mut().iter_mut()) + .zip(self.model.actuator_ctrlrange()) + .zip(self.model.actuator_ctrllimited()) + { + ui.label(RichText::new(actuator_name).font(MAIN_FONT)); + + let range_inc = if *limited { + range[0]..=range[1] + } else { -1.0..=1.0 }; + + ui.add(egui::Slider::new(ctrl, range_inc)); + ui.end_row(); + } + }); + + // Clear all actuator controls by setting them to 0 + if ui.button(RichText::new("Clear").font(MAIN_FONT)).clicked() { + data.ctrl_mut().fill(0.0); + } + + ui.take_available_space(); + }); + + // Joints window + egui::Window::new("Joints") + .default_open(false) + .fade_in(false) + .fade_out(false) + .scroll(true) + .show(ctx, |ui| + { + egui::Grid::new("joint_grid").show(ui, |ui| { + let qpos = data.qpos(); + for ((value, range, limited), joint_name) in self.model.jnt_range().iter().enumerate() + .zip(self.model.jnt_limited()) + .zip(self.model.jnt_type()) + .filter_map(|(((jnt_id, range), limited), type_)| { + // Filter joints with more than one degree of freedom as that's the only + // joint we keep track the name for in the joint_names attribute. + match type_ { + MjtJoint::mjJNT_SLIDE | MjtJoint::mjJNT_HINGE => { + Some((qpos[self.model.jnt_qposadr()[jnt_id] as usize], range, limited)) + } + _ => None + } + }) + .zip(self.joint_names.iter()) + { + ui.label(RichText::new(joint_name).font(MAIN_FONT)); + + let value_scaled = if *limited { + (value - range[0]) / (range[1] - range[0]) + } else { value.clamp(0.0, 1.0) }; + + let [ mut low, mut high] = *range; + ui.add_enabled(true, egui::DragValue::new(&mut value.clone())); + ui.add_enabled(false, egui::DragValue::new(&mut low)); + ui.add(egui::ProgressBar::new(value_scaled as f32)); + ui.add_enabled(false, egui::DragValue::new(&mut high)); + ui.end_row(); + } + }); + + ui.take_available_space(); + }); + + // Prevent window interactions when covering egui widgets + covered = ctx.is_pointer_over_area(); + } + }); + + self.state.handle_platform_output(&window, full_output.platform_output); + + // Tesselate + let pixels_per_point = full_output.pixels_per_point; + let textures_delta = &full_output.textures_delta; + let clipped_primitives = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point); + + // Paint the menu + unsafe { self.gl.polygon_mode(FRONT_AND_BACK, FILL) }; + self.painter.paint_and_update_textures( + window.inner_size().into(), + pixels_per_point, + &clipped_primitives, + textures_delta + ); + + (left, covered) + } + + /// Resets OpenGL state. This is needed for MuJoCo's renderer. + pub(crate) fn reset(&mut self) { + let gl = &self.gl; + unsafe { + gl.use_program(None); + } + } + + /// Drains events from queue. If no event is queued, [`None`] is returned. + pub(crate) fn drain_events(&mut self) -> Option { + self.events.pop_front() + } +} + +/// Implement an empty shell to support use in [`MjViewer`](super::MjViewer). +impl> Debug for ViewerUI { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ViewerUI {{ .. }}") + } +} + +pub(crate) enum UiEvent { + Close, + Fullscreen, + ResetSimulation, + AlignCamera +}