mujoco_rs/viewer.rs
1//! Module related to implementation of the [`MjViewer`]. For implementation of the C++ wrapper,
2//! see [`crate::cpp_viewer::MjViewerCpp`] (enabled by the `cpp-viewer` cargo feature).
3
4use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
5#[cfg(feature = "viewer-ui")] use std::ffi::CString;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::time::{Duration, Instant};
8use std::collections::BTreeSet;
9use std::ops::DerefMut;
10use std::num::NonZero;
11use std::error::Error;
12use std::fmt::Display;
13use std::borrow::Cow;
14
15use winit::event::{ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, WindowEvent};
16#[cfg(feature = "viewer-ui")] use glutin::display::{GetGlDisplay, GlDisplay};
17#[cfg(feature = "viewer-ui")] use egui_glow::glow::{self, HasContext};
18use winit::platform::pump_events::EventLoopExtPumpEvents;
19use glutin::surface::{GlSurface, Surface, WindowSurface};
20use glutin::prelude::PossiblyCurrentGlContext;
21use winit::keyboard::{KeyCode, PhysicalKey};
22use log::{debug, error, info, warn};
23use winit::event_loop::EventLoop;
24use winit::dpi::PhysicalPosition;
25use winit::window::Fullscreen;
26use bitflags::bitflags;
27
28use crate::wrappers::mj_rendering::{MjrContext, MjrRectangle, MjtFont, MjtGridPos};
29use crate::util::{optional_sparse_addr_range, LockUnpoison, ThreeWayMerge};
30use crate::vis_common::{sync_geoms, flip_image_vertically, write_png};
31use crate::wrappers::mj_auxiliary::{MjVisual, MjStatistic};
32use crate::winit_gl_base::{RenderBaseGlState, RenderBase};
33use crate::wrappers::mj_primitive::{MjtNum, MjtSize};
34use crate::wrappers::mj_data::{MjData, MjtState};
35use crate::wrappers::mj_model::traits::ModelType;
36use crate::{builder_setters, mujoco_version};
37use crate::wrappers::mj_option::MjOption;
38use crate::wrappers::mj_visualization::*;
39use crate::wrappers::mj_editing::MjSpec;
40use crate::wrappers::mj_model::MjModel;
41
42
43#[cfg(feature = "viewer-ui")]
44mod ui;
45
46// Re-export egui for user convenience when using custom UI callbacks
47#[cfg(feature = "viewer-ui")]
48pub use egui;
49
50
51// Rust native viewer
52const MJ_VIEWER_DEFAULT_SIZE_PX: (u32, u32) = (1280, 720);
53const DOUBLE_CLICK_WINDOW_MS: u128 = 250;
54const TOUCH_BAR_ZOOM_FACTOR: f64 = 0.1;
55const FPS_SMOOTHING_FACTOR: f64 = 0.1;
56const REALTIME_FACTOR_SMOOTHING_FACTOR: f64 = 0.1;
57const REALTIME_FACTOR_DISPLAY_THRESHOLD: f64 = 0.02;
58
59/// How much extra room to create in the internal [`MjvScene`]. Useful for drawing labels, etc.
60pub(crate) const EXTRA_SCENE_GEOM_SPACE: usize = 2000;
61
62const HELP_MENU_TITLES: &str = concat!(
63 "Toggle help\n",
64 "Toggle info\n",
65 "Toggle v-sync\n",
66 "Toggle realtime check\n",
67 "Toggle full screen\n",
68 "Free camera\n",
69 "Track camera\n",
70 "Camera orbit\n",
71 "Camera pan\n",
72 "Camera look at\n",
73 "Zoom\n",
74 "Object select\n",
75 "Selection rotate\n",
76 "Selection translate\n",
77 "Exit\n",
78 "Reset simulation\n",
79 "Cycle cameras\n",
80 "Visualization toggles",
81);
82
83const HELP_MENU_VALUES: &str = concat!(
84 "F1\n",
85 "F2\n",
86 "F3\n",
87 "F4\n",
88 "F5\n",
89 "Escape\n",
90 "Control + Alt + double-left click\n",
91 "Left drag\n",
92 "Right [+Shift] drag\n",
93 "Alt + double-left click\n",
94 "Zoom, middle drag\n",
95 "Double-left click\n",
96 "Control + [Shift] + drag\n",
97 "Control + Alt + [Shift] + drag\n",
98 "Control + Q\n",
99 "Backspace\n",
100 "[ ]\n",
101 "See MjViewer docs"
102);
103
104// Compile-time check: HELP_MENU_TITLES and HELP_MENU_VALUES must have the same
105// number of newline-delimited entries, otherwise the help overlay misaligns.
106const fn count_byte(s: &[u8], target: u8) -> usize {
107 let mut count = 0;
108 let mut i = 0;
109 while i < s.len() {
110 if s[i] == target {
111 count += 1;
112 }
113 i += 1;
114 }
115 count
116}
117const _: () = assert!(
118 count_byte(HELP_MENU_TITLES.as_bytes(), b'\n')
119 == count_byte(HELP_MENU_VALUES.as_bytes(), b'\n'),
120 "HELP_MENU_TITLES and HELP_MENU_VALUES must have the same number of lines"
121);
122
123/// Errors that can occur when initializing or running the MuJoCo viewer.
124#[derive(Debug)]
125#[non_exhaustive]
126pub enum MjViewerError {
127 /// The event loop failed to initialize.
128 EventLoopError(winit::error::EventLoopError),
129 /// A glutin operation failed.
130 GlutinError(glutin::error::Error),
131 /// Returned when the egui painter (OpenGL UI renderer) fails to initialize.
132 PainterInitError(String),
133 /// OpenGL / window initialization failed.
134 GlInitFailed(crate::error::GlInitError),
135 /// A scene operation failed (e.g. user-scene sync overflowed the geom buffer).
136 SceneError(crate::error::MjSceneError),
137 /// A rendering-context operation failed (e.g. a pixel-read buffer is too small or the
138 /// viewport has invalid dimensions).
139 ContextError(crate::error::MjrContextError),
140 /// The model is not compatible with the viewer's passive model.
141 /// Call [`ViewerSharedState::sync_model`] or [`ViewerSharedState::sync_data`] first.
142 IncompatibleModel,
143 /// The asset ID is out of range.
144 IndexOutOfBounds {
145 /// The ID that was supplied.
146 id: usize,
147 /// The number of assets in the model (the valid range is `0..len`).
148 len: usize,
149 },
150}
151
152/// Formats a human-readable description of the viewer error.
153impl Display for MjViewerError {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 match self {
156 Self::EventLoopError(_) => write!(f, "event loop failed to initialize"),
157 Self::GlutinError(_) => write!(f, "glutin operation failed"),
158 Self::PainterInitError(e) => write!(f, "failed to initialize egui painter: {e}"),
159 Self::GlInitFailed(_) => write!(f, "GL initialization failed"),
160 Self::SceneError(_) => write!(f, "scene operation failed"),
161 Self::ContextError(_) => write!(f, "rendering context operation failed"),
162 Self::IncompatibleModel => write!(f, "the model is not compatible with the passive model: call sync_model / sync_data first"),
163 Self::IndexOutOfBounds { id, len } => write!(f, "asset id {id} is out of range [0, {len})"),
164 }
165 }
166}
167
168/// Provides the underlying error source, if any.
169impl Error for MjViewerError {
170 fn source(&self) -> Option<&(dyn Error + 'static)> {
171 match self {
172 Self::EventLoopError(e) => Some(e),
173 Self::GlutinError(e) => Some(e),
174 Self::PainterInitError(_) => None,
175 Self::GlInitFailed(e) => Some(e),
176 Self::SceneError(e) => Some(e),
177 Self::ContextError(e) => Some(e),
178 Self::IncompatibleModel | Self::IndexOutOfBounds { .. } => None,
179 }
180 }
181}
182
183/// Converts an [`MjSceneError`](crate::error::MjSceneError) into [`MjViewerError::SceneError`].
184impl From<crate::error::MjSceneError> for MjViewerError {
185 fn from(e: crate::error::MjSceneError) -> Self {
186 Self::SceneError(e)
187 }
188}
189
190/// Converts a [`GlInitError`](crate::error::GlInitError) into [`MjViewerError::GlInitFailed`].
191impl From<crate::error::GlInitError> for MjViewerError {
192 fn from(e: crate::error::GlInitError) -> Self {
193 Self::GlInitFailed(e)
194 }
195}
196
197/// Converts an [`MjrContextError`](crate::error::MjrContextError) into [`MjViewerError::ContextError`].
198impl From<crate::error::MjrContextError> for MjViewerError {
199 fn from(e: crate::error::MjrContextError) -> Self {
200 Self::ContextError(e)
201 }
202}
203
204/// Converts a [`glutin::error::Error`] into [`MjViewerError::GlutinError`].
205impl From<glutin::error::Error> for MjViewerError {
206 fn from(e: glutin::error::Error) -> Self {
207 Self::GlutinError(e)
208 }
209}
210
211/// Internal state that is used by [`MjViewer`] to store
212/// [`MjData`]-related state. This is separate from [`MjViewer`]
213/// to allow use in multi-threaded programs, where the physics part
214/// runs in another thread and syncs the state with the viewer
215/// running in the main thread.
216///
217/// The state can be obtained through [`MjViewer::state`], which will return a reference to an `Arc<Mutex<ViewerSharedState>>`
218/// instance. For scoped access, you may also use [`MjViewer::with_state_lock`].
219#[derive(Debug)]
220pub struct ViewerSharedState {
221 /// This attribute, [`ViewerSharedState::data_passive`] and [`ViewerSharedState::data_passive_state_old`]
222 /// are used together to detect changes made to the state within the viewer.
223 /// This can happen due to changes made through the UI to joints, equalities, actuators, etc.
224 data_passive_state: Box<[MjtNum]>,
225 data_passive_state_old: Box<[MjtNum]>,
226 pub(crate) data_passive: MjData<Box<MjModel>>,
227 pert: MjvPerturb,
228 running: Arc<AtomicBool>,
229 user_scene: MjvScene,
230
231 /* Internals */
232 last_sync_time: Instant,
233 /// Time factor representing the ratio of viewer syncs with model's selected timestep.
234 realtime_factor_smooth: f64,
235 /// Preallocated buffer for storing the new [`MjData`] state.
236 data_state_buffer: Box<[MjtNum]>,
237 /// Timestamp when physics options (opt) were last synced
238 last_opt_sync_time: Instant,
239 /// Timestamp when visualization options (vis) were last synced
240 last_vis_sync_time: Instant,
241 /// Timestamp when statistics (stat) were last synced
242 last_stat_sync_time: Instant,
243
244 /* Model parameter change tracking */
245 prev_opt: MjOption,
246 prev_vis: MjVisual,
247 prev_stat: MjStatistic,
248
249 /* Pending GPU asset re-uploads: contains the IDs to upload on the next render call */
250 texture_reupload_pending: BTreeSet<usize>,
251 mesh_reupload_pending: BTreeSet<usize>,
252 hfield_reupload_pending: BTreeSet<usize>,
253
254 /// Used to skip processing until a new scene has been created inside [`MjViewer::update_scene`].
255 /// The latter method clears this dirty flag.
256 model_reloaded: bool,
257}
258
259impl ViewerSharedState {
260 fn new(model: &MjModel, max_user_geom: usize) -> Self {
261 // Empty values to avoid unnecessary double creation
262 let empty: Box<[MjtNum]> = vec![].into_boxed_slice();
263 let empty_model = Box::new(MjSpec::new().compile().unwrap());
264 let empty_data = MjData::new(empty_model);
265 let empty_scene = MjvScene::new(empty_data.model(), 0);
266
267 let mut shared_state = Self {
268 data_passive: empty_data,
269 data_passive_state: empty.clone(),
270 data_passive_state_old: empty.clone(),
271 data_state_buffer: empty,
272 user_scene: empty_scene,
273 pert: MjvPerturb::default(),
274 running: Arc::new(AtomicBool::new(true)),
275 last_sync_time: Instant::now(),
276 realtime_factor_smooth: 1.0,
277 last_opt_sync_time: Instant::now(),
278 last_vis_sync_time: Instant::now(),
279 last_stat_sync_time: Instant::now(),
280 /* Model parameter change tracking */
281 prev_opt: MjOption::default(),
282 prev_vis: MjVisual::default(),
283 prev_stat: MjStatistic::default(),
284 /* Pending GPU asset re-uploads */
285 texture_reupload_pending: BTreeSet::new(),
286 mesh_reupload_pending: BTreeSet::new(),
287 hfield_reupload_pending: BTreeSet::new(),
288 model_reloaded: false,
289 };
290
291 shared_state.reload_model(model, max_user_geom);
292 // The caller builds its scene, render context, camera and UI caches from the same model,
293 // so the first frame has nothing to rebuild.
294 shared_state.model_reloaded = false;
295 shared_state
296 }
297
298 /// Reinitializes all model-dependent internal state.
299 /// Called on construction and whenever [`_sync_data`](Self::_sync_data) or
300 /// [`sync_model`](Self::sync_model) detects a model change.
301 fn reload_model(&mut self, model: &MjModel, max_user_geom: usize) {
302 // Build every model-dependent value before the first assignment, so a panic part-way
303 // leaves the previous generation whole rather than a mix of two models.
304 let mut data_passive = MjData::new(Box::new(model.clone()));
305 // Compute kinematic state that the viewer uses (only the state exists on a new MjData).
306 data_passive.forward();
307 let model_passive = data_passive.model();
308 let user_scene = MjvScene::new(model_passive, max_user_geom);
309 let state_size = model_passive.state_size(MjtState::mjSTATE_INTEGRATION as u32);
310 let mut data_passive_state = vec![0.0; state_size].into_boxed_slice();
311 let prev_opt = model_passive.opt().clone();
312 let prev_vis = model_passive.vis().clone();
313 let prev_stat = model_passive.stat().clone();
314
315 // Read the actual initial state (qpos0 may be non-zero) so that data_passive_state_old
316 // matches data_passive_state from the start, preventing a spurious write-back of the
317 // default pose to the incoming data on the first sync after a model change.
318 data_passive.read_state_into(
319 MjtState::mjSTATE_INTEGRATION as u32,
320 &mut data_passive_state,
321 );
322
323 self.data_passive_state_old = data_passive_state.clone();
324 self.data_state_buffer = data_passive_state.clone();
325 self.data_passive_state = data_passive_state;
326 self.data_passive = data_passive;
327 self.user_scene = user_scene;
328 self.prev_opt = prev_opt; // opt, vis and state would be resyced using the three-way-merge.
329 self.prev_vis = prev_vis;
330 self.prev_stat = prev_stat;
331 self.realtime_factor_smooth = 1.0;
332 self.pert = MjvPerturb::default();
333 // Clear pending re-uploads: the new context will already have the model's
334 // GPU resources loaded from scratch.
335 self.texture_reupload_pending.clear();
336 self.mesh_reupload_pending.clear();
337 self.hfield_reupload_pending.clear();
338 self.model_reloaded = true;
339 }
340
341 /// Checks whether the viewer is still running or is supposed to run.
342 pub fn running(&self) -> bool {
343 self.running.load(Ordering::Relaxed)
344 }
345
346 /// Returns an immutable reference to a user scene for drawing custom visual-only geoms.
347 /// Geoms in the user scene are preserved between calls to [`ViewerSharedState::sync_data`],
348 /// but a sync that detects a model change discards them.
349 pub fn user_scene(&self) -> &MjvScene {
350 &self.user_scene
351 }
352
353 /// Returns a mutable reference to a user scene for drawing custom visual-only geoms.
354 /// Geoms in the user scene are preserved between calls to [`ViewerSharedState::sync_data`],
355 /// but a sync that detects a model change discards them.
356 pub fn user_scene_mut(&mut self) -> &mut MjvScene {
357 &mut self.user_scene
358 }
359
360 /// Performs a bidirectional three-way merge of model's `opt`, `vis`, and `stat` parameters
361 /// between the viewer's passive model and the incoming model.
362 ///
363 /// Reloads the internal state when `model` is no longer compatible with the passive model.
364 pub fn sync_model(&mut self, model: &mut MjModel) {
365 if !self.data_passive.model().is_compatible_with_model(model) {
366 // Model changed: reload internal state.
367 let max_user_geom = self.user_scene.maxgeom() as usize;
368 self.reload_model(model, max_user_geom);
369 }
370
371 self.sync_model_opt(model.opt_mut());
372 self.sync_model_vis(model.vis_mut());
373 self.sync_model_stat(model.stat_mut());
374 }
375
376 /// Performs a bidirectional three-way merge of [`MjModel::opt`] between the viewer's
377 /// passive model and the provided option struct.
378 ///
379 /// Changes made by the viewer UI are written to `opt`; changes made to `opt` externally
380 /// are written back to the viewer's passive model. The merge runs per field, and an array
381 /// counts as one field: a change made to `opt` outside the viewer is lost only when the viewer
382 /// changed that same field since the previous sync.
383 pub fn sync_model_opt(&mut self, opt: &mut MjOption) {
384 ThreeWayMerge::merge(opt, self.data_passive.model_opt_mut(), &mut self.prev_opt);
385 self.last_opt_sync_time = Instant::now();
386 }
387
388 /// Performs a bidirectional three-way merge of [`MjModel::vis`] between the viewer's
389 /// passive model and the provided visual struct.
390 ///
391 /// Changes made by the viewer UI are written to `vis`; changes made to `vis` externally
392 /// are written back to the viewer's passive model. The merge runs per field, and an array
393 /// counts as one field: a change made to `vis` outside the viewer is lost only when the viewer
394 /// changed that same field since the previous sync.
395 pub fn sync_model_vis(&mut self, vis: &mut MjVisual) {
396 ThreeWayMerge::merge(vis, self.data_passive.model_vis_mut(), &mut self.prev_vis);
397 self.last_vis_sync_time = Instant::now();
398 }
399
400 /// Performs a bidirectional three-way merge of [`MjModel::stat`] between the viewer's
401 /// passive model and the provided statistic struct.
402 ///
403 /// Changes made by the viewer UI are written to `stat`; changes made to `stat` externally
404 /// are written back to the viewer's passive model. The merge runs per field, and an array
405 /// counts as one field: a change made to `stat` outside the viewer is lost only when the viewer
406 /// changed that same field since the previous sync.
407 pub fn sync_model_stat(&mut self, stat: &mut MjStatistic) {
408 ThreeWayMerge::merge(stat, self.data_passive.model_stat_mut(), &mut self.prev_stat);
409 self.last_stat_sync_time = Instant::now();
410 }
411
412 /// Returns the last time physics options (opt) were synced.
413 pub fn last_opt_sync_time(&self) -> Instant {
414 self.last_opt_sync_time
415 }
416
417 /// Returns the last time visualization options (vis) were synced.
418 pub fn last_vis_sync_time(&self) -> Instant {
419 self.last_vis_sync_time
420 }
421
422 /// Returns the last time statistics (stat) were synced.
423 pub fn last_stat_sync_time(&self) -> Instant {
424 self.last_stat_sync_time
425 }
426
427 /// Tests the asset memory alone: an update below overrides the asset data, and reaches no
428 /// `mjData` buffer, so a model that only sizes its data differently still fits.
429 fn check_asset_compatible(&self, model: &MjModel) -> Result<(), MjViewerError> {
430 if !self.data_passive.model().is_asset_compatible_with_model(model) {
431 return Err(MjViewerError::IncompatibleModel);
432 }
433 Ok(())
434 }
435
436 /// Copies the texture with `texture_id` from `model` into the viewer's internal passive model
437 /// and schedules a GPU re-upload for the next [`MjViewer::render`] call.
438 ///
439 /// The upload is processed on the next call to [`MjViewer::render`], at which point
440 /// the updated texture will be reflected in the scene.
441 ///
442 /// # Errors
443 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
444 /// - [`MjViewerError::IndexOutOfBounds`] if `texture_id >= model.ntex()`.
445 pub fn update_texture_from(&mut self, model: &MjModel, texture_id: usize) -> Result<(), MjViewerError> {
446 self.check_asset_compatible(model)?;
447 let ntex = model.ntex() as usize;
448 if texture_id >= ntex {
449 return Err(MjViewerError::IndexOutOfBounds { id: texture_id, len: ntex });
450 }
451 // By-reference textures (tex_adr == -1) are always re-read from files.
452 if let Ok(tex_adr) = usize::try_from(model.tex_adr()[texture_id]) {
453 let tex_width = model.tex_width()[texture_id] as usize;
454 let tex_height = model.tex_height()[texture_id] as usize;
455 let tex_nchannel = model.tex_nchannel()[texture_id] as usize;
456 let tex_len = tex_width * tex_height * tex_nchannel;
457 // SAFETY: the asset memory and the bounds are verified above; tex_len is derived from
458 // the texture's own metadata, so the slice cannot exceed the allocation.
459 unsafe { self.data_passive.model_mut() }
460 .tex_data_mut()[tex_adr..tex_adr + tex_len]
461 .copy_from_slice(&model.tex_data()[tex_adr..tex_adr + tex_len]);
462 }
463 self.texture_reupload_pending.insert(texture_id);
464 Ok(())
465 }
466
467 /// Copies all textures from `model` into the viewer's internal passive model
468 /// and schedules a GPU re-upload for the next [`MjViewer::render`] call.
469 ///
470 /// # Errors
471 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
472 pub fn update_textures_from(&mut self, model: &MjModel) -> Result<(), MjViewerError> {
473 self.check_asset_compatible(model)?;
474 // SAFETY: the asset memory is verified above, so the tex_data layouts match exactly.
475 unsafe { self.data_passive.model_mut() }
476 .tex_data_mut()
477 .copy_from_slice(model.tex_data());
478 self.texture_reupload_pending.extend(0..model.ntex() as usize);
479 Ok(())
480 }
481
482 /// Copies the mesh with `mesh_id` from `model` into the viewer's internal passive model
483 /// and schedules a GPU re-upload for the next [`MjViewer::render`] call.
484 ///
485 /// All data arrays read by `mjr_uploadMesh` are copied: vertex positions
486 /// (`mesh_vert`), per-vertex normals (`mesh_normal`), UV texture coordinates
487 /// (`mesh_texcoord`), face-vertex indices (`mesh_face`), face-normal indices
488 /// (`mesh_facenormal`), face-texcoord indices (`mesh_facetexcoord`), and convex
489 /// hull graph data (`mesh_graph`).
490 ///
491 /// The upload is processed on the next call to [`MjViewer::render`], at which point
492 /// the updated mesh will be reflected in the scene.
493 ///
494 /// # Errors
495 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
496 /// - [`MjViewerError::IndexOutOfBounds`] if `mesh_id >= model.nmesh()`.
497 pub fn update_mesh_from(&mut self, model: &MjModel, mesh_id: usize) -> Result<(), MjViewerError> {
498 self.check_asset_compatible(model)?;
499 let nmesh = model.nmesh() as usize;
500 if mesh_id >= nmesh {
501 return Err(MjViewerError::IndexOutOfBounds { id: mesh_id, len: nmesh });
502 }
503 let vert_adr = model.mesh_vertadr()[mesh_id] as usize;
504 let vert_num = model.mesh_vertnum()[mesh_id] as usize;
505 let normal_adr = model.mesh_normaladr()[mesh_id] as usize;
506 let normal_num = model.mesh_normalnum()[mesh_id] as usize;
507 let texcoord_adr = model.mesh_texcoordadr()[mesh_id];
508 let texcoord_num = model.mesh_texcoordnum()[mesh_id] as usize;
509 let face_adr = model.mesh_faceadr()[mesh_id] as usize;
510 let face_num = model.mesh_facenum()[mesh_id] as usize;
511 let graph_range = optional_sparse_addr_range(model.mesh_graphadr(), mesh_id, model.mesh_graph().len());
512 // SAFETY: no model swap happens here; only mesh values change.
513 let passive = unsafe { self.data_passive.model_mut() };
514 passive.mesh_vert_mut()[vert_adr..vert_adr + vert_num]
515 .copy_from_slice(&model.mesh_vert()[vert_adr..vert_adr + vert_num]);
516 passive.mesh_normal_mut()[normal_adr..normal_adr + normal_num]
517 .copy_from_slice(&model.mesh_normal()[normal_adr..normal_adr + normal_num]);
518 if texcoord_adr >= 0 {
519 let texcoord_adr = texcoord_adr as usize;
520 passive.mesh_texcoord_mut()[texcoord_adr..texcoord_adr + texcoord_num]
521 .copy_from_slice(&model.mesh_texcoord()[texcoord_adr..texcoord_adr + texcoord_num]);
522 }
523 unsafe {
524 passive.mesh_face_mut()[face_adr..face_adr + face_num]
525 .copy_from_slice(&model.mesh_face()[face_adr..face_adr + face_num]);
526 passive.mesh_facenormal_mut()[face_adr..face_adr + face_num]
527 .copy_from_slice(&model.mesh_facenormal()[face_adr..face_adr + face_num]);
528 passive.mesh_facetexcoord_mut()[face_adr..face_adr + face_num]
529 .copy_from_slice(&model.mesh_facetexcoord()[face_adr..face_adr + face_num]);
530 if let Some((graph_adr, mesh_graph_len)) = graph_range {
531 passive.mesh_graph_mut()[graph_adr..graph_adr + mesh_graph_len]
532 .copy_from_slice(&model.mesh_graph()[graph_adr..graph_adr + mesh_graph_len]);
533 }
534 }
535 self.mesh_reupload_pending.insert(mesh_id);
536 Ok(())
537 }
538
539 /// Copies all meshes from `model` into the viewer's internal passive model
540 /// and schedules a GPU re-upload for the next [`MjViewer::render`] call.
541 ///
542 /// All data arrays read by `mjr_uploadMesh` are bulk-copied: vertex positions
543 /// (`mesh_vert`), per-vertex normals (`mesh_normal`), UV texture coordinates
544 /// (`mesh_texcoord`), face-vertex indices (`mesh_face`), face-normal indices
545 /// (`mesh_facenormal`), face-texcoord indices (`mesh_facetexcoord`), and convex
546 /// hull graph data (`mesh_graph`).
547 ///
548 /// # Errors
549 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
550 pub fn update_meshes_from(&mut self, model: &MjModel) -> Result<(), MjViewerError> {
551 self.check_asset_compatible(model)?;
552 // SAFETY: no model swap happens here; only mesh values change.
553 let passive = unsafe { self.data_passive.model_mut() };
554 passive.mesh_vert_mut().copy_from_slice(model.mesh_vert());
555 passive.mesh_normal_mut().copy_from_slice(model.mesh_normal());
556 passive.mesh_texcoord_mut().copy_from_slice(model.mesh_texcoord());
557 unsafe {
558 passive.mesh_face_mut().copy_from_slice(model.mesh_face());
559 passive.mesh_facenormal_mut().copy_from_slice(model.mesh_facenormal());
560 passive.mesh_facetexcoord_mut().copy_from_slice(model.mesh_facetexcoord());
561 passive.mesh_graph_mut().copy_from_slice(model.mesh_graph());
562 }
563 self.mesh_reupload_pending.extend(0..model.nmesh() as usize);
564 Ok(())
565 }
566
567 /// Copies the heightfield with `hfield_id` from `model` into the viewer's internal passive model
568 /// and schedules a GPU re-upload for the next [`MjViewer::render`] call.
569 ///
570 /// The upload is processed on the next call to [`MjViewer::render`], at which point
571 /// the updated heightfield will be reflected in the scene.
572 ///
573 /// # Errors
574 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
575 /// - [`MjViewerError::IndexOutOfBounds`] if `hfield_id >= model.nhfield()`.
576 pub fn update_hfield_from(&mut self, model: &MjModel, hfield_id: usize) -> Result<(), MjViewerError> {
577 self.check_asset_compatible(model)?;
578 let nhfield = model.nhfield() as usize;
579 if hfield_id >= nhfield {
580 return Err(MjViewerError::IndexOutOfBounds { id: hfield_id, len: nhfield });
581 }
582 let hfield_adr = model.hfield_adr()[hfield_id] as usize;
583 let hfield_nrow = model.hfield_nrow()[hfield_id] as usize;
584 let hfield_ncol = model.hfield_ncol()[hfield_id] as usize;
585 let hfield_len = hfield_nrow * hfield_ncol;
586 // SAFETY: no model swap happens here; only hfield_data values change.
587 unsafe { self.data_passive.model_mut() }
588 .hfield_data_mut()[hfield_adr..hfield_adr + hfield_len]
589 .copy_from_slice(&model.hfield_data()[hfield_adr..hfield_adr + hfield_len]);
590 self.hfield_reupload_pending.insert(hfield_id);
591 Ok(())
592 }
593
594 /// Copies all heightfields from `model` into the viewer's internal passive model
595 /// and schedules a GPU re-upload for the next [`MjViewer::render`] call.
596 ///
597 /// # Errors
598 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
599 pub fn update_hfields_from(&mut self, model: &MjModel) -> Result<(), MjViewerError> {
600 self.check_asset_compatible(model)?;
601 // SAFETY: no model swap happens here; only hfield_data values change.
602 unsafe { self.data_passive.model_mut() }
603 .hfield_data_mut()
604 .copy_from_slice(model.hfield_data());
605 self.hfield_reupload_pending.extend(0..model.nhfield() as usize);
606 Ok(())
607 }
608
609
610 /// Same as [`ViewerSharedState::sync_data`], except it copies the entire [`MjData`]
611 /// struct (including large Jacobian and other arrays), not just the state needed for visualization.
612 ///
613 /// # Panics
614 /// Panics if the internal data copy or state merge fails due to an inconsistent model
615 /// state (indicates a bug).
616 pub fn sync_data_full<M: ModelType>(&mut self, data: &mut MjData<M>) {
617 self._sync_data(data, true);
618 }
619
620 /// Syncs the viewer's internal passive [`MjData`] with `data`.
621 /// Synchronization happens in three steps:
622 ///
623 /// 1. The viewer checks if any changes have been made to the internal [`MjData`]
624 /// since the last call to this method (since the last sync). Changed elements
625 /// are selectively merged into `data` (elements the viewer did not touch are
626 /// preserved).
627 /// 2. `data` is copied into the viewer's internal passive copy
628 /// (visualization fields only; see warning below).
629 /// 3. Perturbations are applied to `data` via [`MjvPerturb::apply`], which
630 /// **unconditionally zeroes `xfrc_applied`** before writing any active
631 /// perturbation forces. Any external forces previously set on `data` will be
632 /// cleared.
633 ///
634 /// Note that users must afterward call [`MjViewer::render`] for the scene
635 /// to be rendered and the UI to be processed.
636 ///
637 /// <div class="warning">
638 /// The user's data is copied into the viewer's internal passive copy via ``mjv_copyData``,
639 /// which skips large computed arrays not required for visualization. In the viewer's passive
640 /// copy:
641 ///
642 /// - the mass and factorization matrices (``crb``, ``M``, ``qLD``, ``qH``, ``qDeriv``,
643 /// ``qLU``) keep the values of the previous copy, because they live in the fixed buffer
644 /// that ``mjv_copyData`` does not overwrite;
645 /// - the sparse constraint Jacobian blocks (``efc_J``, ``efc_Y``, ``efc_AR`` and their
646 /// index arrays) are empty, because they live in the arena, which the copy leaves
647 /// unallocated.
648 ///
649 /// In UI callbacks these fields are therefore stale or empty unless
650 /// [`ViewerSharedState::sync_data_full`] is used or they are recomputed explicitly
651 /// (e.g. via `data.forward()`).
652 ///
653 /// Additionally, because the viewer may write integration state (e.g. ``ctrl``) back
654 /// to the user's `data`, any Jacobians or other derived quantities in `data` may be
655 /// stale after this call and should be recomputed if needed.
656 /// </div>
657 ///
658 /// # Panics
659 /// Panics if the internal data copy or state merge fails due to an inconsistent model
660 /// state (indicates a bug).
661 pub fn sync_data<M: ModelType>(&mut self, data: &mut MjData<M>) {
662 self._sync_data(data, false);
663 }
664
665 /// Data sync implementation.
666 fn _sync_data<M: ModelType>(&mut self, data: &mut MjData<M>, full_sync: bool) {
667 // Recreate internal data and user scene when the model changes
668 if !self.data_passive.model().is_compatible_with_model(data.model()) {
669 let max_user_geom = self.user_scene.maxgeom() as usize;
670 self.reload_model(data.model(), max_user_geom);
671 }
672
673 // Update statistics
674 let passive_time = self.data_passive.time();
675 let active_time = data.time();
676 if passive_time > 0.0 && active_time > passive_time { // time = 0 means data was reset
677 let time_elapsed_sim = active_time - passive_time;
678 let elapsed_sync = self.last_sync_time.elapsed();
679 if !elapsed_sync.is_zero() {
680 self.realtime_factor_smooth += REALTIME_FACTOR_SMOOTHING_FACTOR * (
681 time_elapsed_sim / elapsed_sync.as_secs_f64()
682 - self.realtime_factor_smooth
683 );
684 }
685 } else { // simulation was reset
686 self.realtime_factor_smooth = 1.0;
687 }
688
689 self.last_sync_time = Instant::now();
690
691 // Sync
692 self.data_passive.read_state_into(
693 MjtState::mjSTATE_INTEGRATION as u32,
694 &mut self.data_passive_state
695 );
696 if self.data_passive_state != self.data_passive_state_old {
697 data.read_state_into(MjtState::mjSTATE_INTEGRATION as u32, &mut self.data_state_buffer);
698 for ((new, passive), passive_old) in self.data_state_buffer.iter_mut()
699 .zip(&mut self.data_passive_state)
700 .zip(&mut self.data_passive_state_old)
701 {
702 if *passive_old != *passive {
703 *new = *passive;
704 }
705 }
706
707 data.set_state(&self.data_state_buffer, MjtState::mjSTATE_INTEGRATION as u32)
708 .unwrap();
709 }
710
711 if full_sync {
712 // Copy everything.
713 data.copy_to(&mut self.data_passive)
714 .unwrap();
715 } else {
716 // Copy only visually-required information to the internal passive data.
717 data.copy_visual_to(&mut self.data_passive)
718 .unwrap();
719 }
720
721 // Make both saved states the same.
722 // If any modification is made through the viewer
723 // between syncs, then the above if block will trigger a transfer.
724 self.data_passive.read_state_into( // read to match the synced passive MjData
725 MjtState::mjSTATE_INTEGRATION as u32,
726 &mut self.data_passive_state
727 );
728 self.data_passive_state_old.copy_from_slice(&self.data_passive_state);
729
730 // Apply perturbations
731 self.pert.apply(data);
732 }
733}
734
735
736/// A Rust-native implementation of the MuJoCo viewer. To conform to Rust's safety rules,
737/// the viewer doesn't store a mutable reference to the [`MjData`] struct, but it instead
738/// accepts it as a parameter in its methods.
739///
740/// The [`MjViewer::sync_data`] method must be called to sync the state of [`MjViewer`] and [`MjData`].
741///
742/// # Shortcuts
743/// Main keyboard and mouse shortcuts can be viewed by pressing `F1`.
744/// Additionally, some visualization toggles are included, but not displayed
745/// in the `F1` help menu:
746/// - C: camera,
747/// - U: actuator,
748/// - J: joint,
749/// - M: center of mass,
750/// - H: convex hull,
751/// - Z: light,
752/// - T: transparent,
753/// - I: inertia,
754/// - E: constraint.
755///
756/// With the `viewer-ui` feature, `X` toggles the side UI panel. The side panel also closes and
757/// opens by a drag of its inner edge to and from the screen edge.
758///
759/// # Panics
760/// Panics when initialized outside the main thread.
761#[derive(Debug)]
762pub struct MjViewer {
763 /* MuJoCo rendering */
764 scene: MjvScene,
765 context: MjrContext,
766 camera: MjvCamera,
767
768 /* Other MuJoCo related */
769 opt: MjvOption,
770
771 /* Internal state */
772 last_x: f64,
773 last_y: f64,
774 last_bnt_press_time: Instant,
775 rect_view: MjrRectangle,
776 rect_full: MjrRectangle,
777 fps_timer: Instant,
778 fps_smooth: f64,
779
780 /* OpenGL */
781 adapter: RenderBase,
782 event_loop: EventLoop<()>,
783 modifiers: Modifiers,
784 buttons_pressed: ButtonsPressed,
785 raw_cursor_position: (f64, f64),
786
787 /* External interaction */
788 shared_state: Arc<Mutex<ViewerSharedState>>,
789 /// Shared atomic flag cloned from [`ViewerSharedState::running`].
790 /// Allows [`MjViewer::running`] to read the flag without locking the mutex on every call
791 /// (the user's sim loop polls this on every iteration).
792 running_flag: Arc<AtomicBool>,
793
794 /* User interface */
795 #[cfg(feature = "viewer-ui")]
796 ui: ui::ViewerUI,
797
798 status: ViewerStatusBit,
799
800 /// Cached number of MJCF-defined cameras (`model.ncam`).
801 /// Updated in [`update_scene`](Self::update_scene) whenever the model changes.
802 /// Avoids locking `shared_state` just to read this integer in [`cycle_camera`](Self::cycle_camera).
803 ncam: MjtSize,
804
805 /// Pending screenshot request. [`Some`] with `(viewport_only, depth)` flags when a
806 /// screenshot is queued; [`None`] otherwise.
807 screenshot_pending: Option<(bool, bool)>,
808
809 /// Proxy for controlling OpenGL directly.
810 #[cfg(feature = "viewer-ui")]
811 gl: Arc<egui_glow::glow::Context>,
812}
813
814impl MjViewer {
815 /// Launches the MuJoCo viewer. A [`Result`] struct is returned that either contains
816 /// [`MjViewer`] or a [`MjViewerError`]. The `max_user_geom` parameter
817 /// defines how much space will be allocated for additional, user-defined visual-only geoms.
818 /// It can thus be set to 0 if no additional geoms will be drawn by the user.
819 ///
820 /// Note that the use of [`MjViewerBuilder`] is preferred, because it is more flexible.
821 /// Call [`MjViewer::builder`] to create a [`MjViewerBuilder`] instance.
822 /// # Returns
823 /// On success, returns [`Ok`] variant containing the [`MjViewer`].
824 /// # Errors
825 /// - [`MjViewerError::EventLoopError`] if the event loop fails to initialize.
826 /// - [`MjViewerError::GlInitFailed`] if OpenGL / window initialization fails.
827 /// - [`MjViewerError::GlutinError`] if a glutin operation fails.
828 /// - [`MjViewerError::PainterInitError`] if the UI painter fails to initialize
829 /// (feature `viewer-ui`).
830 /// # Panics
831 /// Panics if the GL display reports no usable configuration.
832 pub fn launch_passive(model: &MjModel, max_user_geom: usize) -> Result<Self, MjViewerError> {
833 MjViewerBuilder::new()
834 .max_user_geoms(max_user_geom)
835 .build_passive(model)
836 }
837
838 /// A shortcut for creating an instance of [`MjViewerBuilder`].
839 /// The builder can be used to build the viewer after configuring it.
840 /// It allows better configuration than [`MjViewer::launch_passive`], which
841 /// is fixed to achieve backward compatibility.
842 pub fn builder() -> MjViewerBuilder {
843 MjViewerBuilder::new()
844 }
845
846 /// Checks whether the viewer is still running.
847 pub fn running(&self) -> bool {
848 self.running_flag.load(Ordering::Relaxed)
849 }
850
851 /// Returns a reference to the shared state [`ViewerSharedState`].
852 /// This struct can be used to sync the state of the viewer with
853 /// the simulation, possibly running in another thread.
854 pub fn state(&self) -> &Arc<Mutex<ViewerSharedState>> {
855 &self.shared_state
856 }
857
858 /// Acquires a Mutex lock on the [`MjViewer`]'s shared state ([`MjViewer::state`]).
859 /// The acquired lock is passed to the function/closure `fun`.
860 ///
861 /// # Errors
862 /// Returns [`PoisonError`] if the mutex holding the shared state has panicked, thus poisoning
863 /// the lock.
864 ///
865 /// # Example
866 /// ```no_run
867 /// # use mujoco_rs::viewer::MjViewer;
868 /// # use mujoco_rs::prelude::*;
869 /// # let model = MjModel::from_xml_string("<mujoco/>").unwrap();
870 /// let mut viewer = MjViewer::builder().max_user_geoms(1)
871 /// .build_passive(&model).unwrap();
872 /// viewer.with_state_lock(|mut lock| {
873 /// let scene = lock.user_scene_mut();
874 /// // SAFETY: the geom keeps the material, texture and object ids that `create_geom` zeroes.
875 /// unsafe {
876 /// scene.create_geom(MjtGeom::mjGEOM_BOX, Some([1.0, 1.0, 1.0]), Some([0.0, 0.0, 0.0]), None, None);
877 /// }
878 /// }).unwrap();
879 /// ```
880 pub fn with_state_lock<F, R>(&self, fun: F) -> Result<R, PoisonError<MutexGuard<'_, ViewerSharedState>>>
881 where F: FnOnce(MutexGuard<ViewerSharedState>) -> R
882 {
883 Ok(fun(self.shared_state.lock()?))
884 }
885
886 /// Adds a user-defined UI callback for custom widgets in the viewer's UI.
887 /// The callback receives the root [`egui::Ui`], which creates custom windows, panels or
888 /// other UI elements, and the viewer's [`ViewerSharedState`] (An [`Arc`] `<`[`Mutex`]`>` of it),
889 /// which the callback locks only when it needs the state.
890 ///
891 /// # Note
892 /// If you need the shared-state of the viewer, keep the lock short, because a simulation thread
893 /// that calls [`MjViewer::sync_data`] waits for the same [`Mutex`].
894 ///
895 /// # Model swaps
896 /// Callbacks run inside [`MjViewer::render`]. A sync on another
897 /// thread through [`ViewerSharedState::sync_data`] with `data`'s [`MjData::model`] being a different
898 /// model than the model used to create the viewer, or through [`ViewerSharedState::sync_model`],
899 /// can cause the contents of [`ViewerSharedState`]'s (the `state` parameter) to change when it isn't locked.
900 ///
901 /// If you rely on the shared state to stay the same during the callback, either:
902 /// - don't swap the model without reopening the viewer or
903 /// - lock the shared-state throughout entire callback (but be careful to not create deadlocks) or
904 /// - check the model against the expected model with [`MjModel::is_compatible_with_model`].
905 ///
906 /// # Example
907 /// ```no_run
908 /// # use mujoco_rs::prelude::*;
909 /// # use mujoco_rs::viewer::MjViewer;
910 /// # let model = MjModel::from_xml_string("<mujoco/>").unwrap();
911 /// # let mut viewer = MjViewer::launch_passive(&model, 0).unwrap();
912 /// viewer.add_ui_callback(|ui, state| {
913 /// use mujoco_rs::viewer::egui;
914 /// egui::Window::new("Custom controls")
915 /// .scroll(true)
916 /// .show(ui, |ui| {
917 /// ui.label(format!("running: {}", state.lock().unwrap().running()));
918 /// });
919 /// });
920 /// ```
921 #[cfg(feature = "viewer-ui")]
922 pub fn add_ui_callback<F>(&mut self, callback: F)
923 where
924 F: FnMut(&mut egui::Ui, &Arc<Mutex<ViewerSharedState>>) + 'static
925 {
926 self.ui.add_ui_callback(callback);
927 }
928
929 /// Same as [`MjViewer::sync_data`], except it copies the entire [`MjData`]
930 /// struct (including large Jacobian and other arrays), not just the state needed for visualization.
931 /// This is a proxy to [`ViewerSharedState::sync_data_full`].
932 ///
933 /// # Panics
934 /// Panics if the internal data copy or state merge fails due to an inconsistent model
935 /// state (indicates a bug).
936 pub fn sync_data_full<M: ModelType>(&mut self, data: &mut MjData<M>) {
937 self.shared_state.lock_unpoison().sync_data_full(data);
938 }
939
940 /// Syncs the state of viewer's internal [`MjData`] with `data`.
941 /// This is a proxy to [`ViewerSharedState::sync_data`].
942 ///
943 /// Any changes made to the internal [`MjData`] in between syncs
944 /// get selectively merged back into `data` before the copy.
945 /// Perturbations are applied to `data` **after** the sync, which
946 /// **unconditionally zeroes `xfrc_applied`** (see
947 /// [`ViewerSharedState::sync_data`] for details).
948 ///
949 /// Note that users must afterward call [`MjViewer::render`] for the scene
950 /// to be rendered and the UI to be processed.
951 ///
952 /// <div class="warning">
953 /// The user's data is copied into the viewer's internal passive copy via ``mjv_copyData``,
954 /// which skips large computed arrays not required for visualization. In the viewer's passive
955 /// copy:
956 ///
957 /// - the mass and factorization matrices (``crb``, ``M``, ``qLD``, ``qH``, ``qDeriv``,
958 /// ``qLU``) keep the values of the previous copy, because they live in the fixed buffer
959 /// that ``mjv_copyData`` does not overwrite;
960 /// - the sparse constraint Jacobian blocks (``efc_J``, ``efc_Y``, ``efc_AR`` and their
961 /// index arrays) are empty, because they live in the arena, which the copy leaves
962 /// unallocated.
963 ///
964 /// In UI callbacks these fields are therefore stale or empty unless
965 /// [`MjViewer::sync_data_full`] is used or they are recomputed explicitly
966 /// (e.g. via `data.forward()`).
967 ///
968 /// Additionally, because the viewer may write integration state (e.g. ``ctrl``) back
969 /// to the user's `data`, any Jacobians or other derived quantities in `data` may be
970 /// stale after this call and should be recomputed if needed.
971 /// </div>
972 ///
973 /// # Panics
974 /// Panics if the internal data copy or state merge fails due to an inconsistent model
975 /// state (indicates a bug).
976 ///
977 /// # Example
978 /// ```no_run
979 /// # use mujoco_rs::prelude::*;
980 /// # use mujoco_rs::viewer::MjViewer;
981 /// # let model = MjModel::from_xml("/path/scene.xml").unwrap();
982 /// # let mut viewer = MjViewer::builder().build_passive(&model).unwrap();
983 /// # let mut data = MjData::new(&model);
984 /// viewer.sync_data(&mut data); // sync the data
985 /// viewer.render().unwrap(); // render the scene and process the user interface
986 /// ```
987 pub fn sync_data<M: ModelType>(&mut self, data: &mut MjData<M>) {
988 self.shared_state.lock_unpoison().sync_data(data);
989 }
990
991 /// Performs a bidirectional three-way merge of model parameters between the
992 /// viewer's passive model and the incoming model.
993 /// This is a proxy to [`ViewerSharedState::sync_model`].
994 ///
995 /// Detects model changes with [`MjModel::is_compatible_with_model`] and reloads internal state if needed.
996 /// After a reload, the passive model mirrors the incoming model's defaults, so the
997 /// first merge is effectively a no-op. On subsequent calls, viewer UI changes and
998 /// simulation-side changes are merged bidirectionally.
999 pub fn sync_model(&mut self, model: &mut MjModel) {
1000 self.shared_state.lock_unpoison().sync_model(model);
1001 }
1002
1003 /// Performs a bidirectional three-way merge of [`MjModel::opt`] between the viewer's
1004 /// passive model and the provided option struct.
1005 /// This is a proxy to [`ViewerSharedState::sync_model_opt`].
1006 ///
1007 /// This allows updating physics options without requiring `unsafe` access via
1008 /// [`MjData::model_mut`] or manipulating the viewer's shared state directly.
1009 pub fn sync_model_opt(&mut self, opt: &mut MjOption) {
1010 self.shared_state.lock_unpoison().sync_model_opt(opt);
1011 }
1012
1013 /// Performs a bidirectional three-way merge of [`MjModel::vis`] between the viewer's
1014 /// passive model and the provided visual struct.
1015 /// This is a proxy to [`ViewerSharedState::sync_model_vis`].
1016 ///
1017 /// This allows updating visualization options without requiring `unsafe` access via
1018 /// [`MjData::model_mut`] or manipulating the viewer's shared state directly.
1019 pub fn sync_model_vis(&mut self, vis: &mut MjVisual) {
1020 self.shared_state.lock_unpoison().sync_model_vis(vis);
1021 }
1022
1023 /// Performs a bidirectional three-way merge of [`MjModel::stat`] between the viewer's
1024 /// passive model and the provided statistic struct.
1025 /// This is a proxy to [`ViewerSharedState::sync_model_stat`].
1026 ///
1027 /// This allows updating model statistics without requiring `unsafe` access via
1028 /// [`MjData::model_mut`] or manipulating the viewer's shared state directly.
1029 pub fn sync_model_stat(&mut self, stat: &mut MjStatistic) {
1030 self.shared_state.lock_unpoison().sync_model_stat(stat);
1031 }
1032
1033 /// Copies the texture with `texture_id` from `model` into the viewer's internal passive model
1034 /// and schedules a GPU re-upload for the next [`render`](Self::render) call.
1035 ///
1036 /// This is a proxy to [`ViewerSharedState::update_texture_from`].
1037 ///
1038 /// # Errors
1039 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
1040 /// - [`MjViewerError::IndexOutOfBounds`] if `texture_id >= model.ntex()`.
1041 pub fn update_texture_from(&mut self, model: &MjModel, texture_id: usize) -> Result<(), MjViewerError> {
1042 self.shared_state.lock_unpoison().update_texture_from(model, texture_id)
1043 }
1044
1045 /// Copies all textures from `model` into the viewer's internal passive model
1046 /// and schedules a GPU re-upload for the next [`render`](Self::render) call.
1047 ///
1048 /// This is a proxy to [`ViewerSharedState::update_textures_from`].
1049 ///
1050 /// # Errors
1051 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
1052 pub fn update_textures_from(&mut self, model: &MjModel) -> Result<(), MjViewerError> {
1053 self.shared_state.lock_unpoison().update_textures_from(model)
1054 }
1055
1056 /// Copies the mesh with `mesh_id` from `model` into the viewer's internal passive model
1057 /// and schedules a GPU re-upload for the next [`render`](Self::render) call.
1058 ///
1059 /// This is a proxy to [`ViewerSharedState::update_mesh_from`].
1060 ///
1061 /// # Errors
1062 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
1063 /// - [`MjViewerError::IndexOutOfBounds`] if `mesh_id >= model.nmesh()`.
1064 pub fn update_mesh_from(&mut self, model: &MjModel, mesh_id: usize) -> Result<(), MjViewerError> {
1065 self.shared_state.lock_unpoison().update_mesh_from(model, mesh_id)
1066 }
1067
1068 /// Copies all meshes from `model` into the viewer's internal passive model
1069 /// and schedules a GPU re-upload for the next [`render`](Self::render) call.
1070 ///
1071 /// This is a proxy to [`ViewerSharedState::update_meshes_from`].
1072 ///
1073 /// # Errors
1074 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
1075 pub fn update_meshes_from(&mut self, model: &MjModel) -> Result<(), MjViewerError> {
1076 self.shared_state.lock_unpoison().update_meshes_from(model)
1077 }
1078
1079 /// Copies the heightfield with `hfield_id` from `model` into the viewer's internal passive model
1080 /// and schedules a GPU re-upload for the next [`render`](Self::render) call.
1081 ///
1082 /// This is a proxy to [`ViewerSharedState::update_hfield_from`].
1083 ///
1084 /// # Errors
1085 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
1086 /// - [`MjViewerError::IndexOutOfBounds`] if `hfield_id >= model.nhfield()`.
1087 pub fn update_hfield_from(&mut self, model: &MjModel, hfield_id: usize) -> Result<(), MjViewerError> {
1088 self.shared_state.lock_unpoison().update_hfield_from(model, hfield_id)
1089 }
1090
1091 /// Copies all heightfields from `model` into the viewer's internal passive model
1092 /// and schedules a GPU re-upload for the next [`render`](Self::render) call.
1093 ///
1094 /// This is a proxy to [`ViewerSharedState::update_hfields_from`].
1095 ///
1096 /// # Errors
1097 /// - [`MjViewerError::IncompatibleModel`] if `model` is not compatible with the viewer's passive model.
1098 pub fn update_hfields_from(&mut self, model: &MjModel) -> Result<(), MjViewerError> {
1099 self.shared_state.lock_unpoison().update_hfields_from(model)
1100 }
1101
1102
1103 /// Processes the UI (when enabled), processes events, draws the scene
1104 /// and swaps buffers in OpenGL.
1105 ///
1106 /// # Errors
1107 /// - [`MjViewerError::GlutinError`] if the OpenGL context cannot be made current or the buffer swap fails.
1108 /// - [`MjViewerError::SceneError`] if synchronizing user scene geoms fails (e.g. the scene is
1109 /// full).
1110 /// - [`MjViewerError::ContextError`] if reading pixels for a pending screenshot fails.
1111 pub fn render(&mut self) -> Result<(), MjViewerError> {
1112 let RenderBaseGlState {
1113 gl_context,
1114 gl_surface,
1115 ..
1116 } = self.adapter.state.as_ref().unwrap();
1117
1118 // Make sure everything is done on the viewer's window
1119 gl_context.make_current(gl_surface)?;
1120
1121 // Configure OpenGL to the expected settings by MuJoCo.
1122 #[cfg(feature = "viewer-ui")]
1123 self.init_mujoco_gl();
1124
1125 // Read the screen size
1126 self.update_rectangles(self.adapter.state.as_ref().unwrap().window.inner_size().into());
1127
1128 // Process mouse and keyboard events
1129 self.process_events();
1130
1131 // Build the user menu and process its internal events.
1132 #[cfg(feature = "viewer-ui")]
1133 self.process_user_ui()?;
1134
1135 // Update the scene from data and render
1136 self.update_scene()?;
1137
1138 // Viewport-only screenshot: capture the centered scene before the UI
1139 // panel is drawn on top (the scene is rendered to rect_full, so it
1140 // fills the entire window).
1141 if let Some((true, _)) = self.screenshot_pending {
1142 let (_, depth) = self.screenshot_pending.take().unwrap();
1143 self.capture_screenshot(depth)?;
1144 }
1145
1146 // Update the user menu state and overlays
1147 self.update_menus();
1148
1149 // Draw the user UI.
1150 #[cfg(feature = "viewer-ui")]
1151 self.ui.paint();
1152
1153 // Full-window screenshot: capture after all rendering (UI + overlays).
1154 if let Some((false, _)) = self.screenshot_pending {
1155 let (_, depth) = self.screenshot_pending.take().unwrap();
1156 self.capture_screenshot(depth)?;
1157 }
1158
1159 // Flush to the GPU
1160 self.swap_buffers()
1161 }
1162
1163 /// Requests a close of the viewer. This can go one of two ways, depending on whether the viewer has been compiled
1164 /// with the 'viewer-ui' feature enabled.
1165 ///
1166 /// If the said feature **is enabled**, it will show a confirmation modal to
1167 /// the user.
1168 ///
1169 /// If the said feature **is not enabled**, the viewer will close as soon as possible, thus this method
1170 /// will behave identically to [`MjViewer::force_close`].
1171 pub fn request_close(&mut self) {
1172 cfg_select! {
1173 feature = "viewer-ui" => {
1174 if self.ui.request_close() {
1175 self.force_close(); // The exit modal was already open -> another request is a confirmation.
1176 }
1177 }
1178 _ => { self.force_close(); }
1179 }
1180 }
1181
1182 /// Instructs the viewer to close as soon as possible, without showing any potential exit confirmation.
1183 pub fn force_close(&self) {
1184 self.running_flag.store(false, Ordering::Relaxed);
1185 }
1186
1187 /// Prepares MuJoCo OpenGL for rendering in MuJoCo 3D mode.
1188 #[cfg(feature = "viewer-ui")]
1189 fn init_mujoco_gl(&self) {
1190 let gl = &self.gl;
1191 // SAFETY: the GL context is current.
1192 unsafe {
1193 // Disable shaders
1194 gl.use_program(None);
1195
1196 // Unbind buffers
1197 gl.bind_vertex_array(None);
1198 gl.bind_buffer(glow::ARRAY_BUFFER, None);
1199 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
1200 }
1201 }
1202
1203 /// Perform OpenGL buffer swap.
1204 fn swap_buffers(&self) -> Result<(), MjViewerError> {
1205 let RenderBaseGlState {
1206 gl_context,
1207 gl_surface,
1208 ..
1209 } = self.adapter.state.as_ref().unwrap();
1210
1211 // Swap OpenGL buffers (render to screen)
1212 gl_surface.swap_buffers(gl_context).map_err(MjViewerError::GlutinError)
1213 }
1214
1215 /// Captures the current framebuffer contents as a PNG screenshot.
1216 /// Always reads from [`rect_full`](Self::rect_full) (the entire window).
1217 /// The caller controls what is visible by choosing *when* to call this
1218 /// method in the render pipeline. When `depth` is `true`, a 16-bit
1219 /// grayscale depth image is saved instead of an RGB image.
1220 ///
1221 /// # Errors
1222 /// Returns [`MjViewerError::ContextError`] if reading pixels from the framebuffer fails.
1223 fn capture_screenshot(&self, depth: bool) -> Result<(), MjViewerError> {
1224 let rect = &self.rect_full;
1225
1226 let w = rect.width as usize;
1227 let h = rect.height as usize;
1228 if w == 0 || h == 0 {
1229 return Ok(());
1230 }
1231
1232 // Generate a timestamped filename.
1233 let timestamp = std::time::SystemTime::now()
1234 .duration_since(std::time::UNIX_EPOCH)
1235 .unwrap_or_default()
1236 .as_secs();
1237
1238 if depth {
1239 let mut depth_buf = vec![0.0f32; w * h];
1240 self.context.read_pixels(None, Some(&mut depth_buf), rect)
1241 .map_err(MjViewerError::ContextError)?;
1242
1243 // OpenGL reads bottom-up; flip for top-down PNG row order.
1244 flip_image_vertically(&mut depth_buf, h, w);
1245
1246 // Linearize raw OpenGL depth into metric distance.
1247 let (map, stat) = {
1248 let lock = self.shared_state.lock_unpoison();
1249 let model = lock.data_passive.model();
1250 (model.vis().map.clone(), model.stat().clone())
1251 };
1252 let extent = stat.extent as f32;
1253 let near = map.znear * extent;
1254 let far = map.zfar * extent;
1255 for value in &mut depth_buf {
1256 *value = near / (1.0 - *value * (1.0 - near / far));
1257 }
1258
1259 // Normalize to 16-bit range.
1260 let max = depth_buf.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1261 let min = depth_buf.iter().cloned().fold(f32::INFINITY, f32::min);
1262 let scale = u16::MAX as f32;
1263 let range = max - min;
1264 let encoded: Box<[u8]> = depth_buf
1265 .iter()
1266 .flat_map(|&x| {
1267 let norm = if range > 0.0 { (x - min) / range } else { 0.0 };
1268 ((norm * scale).clamp(0.0, scale) as u16).to_be_bytes()
1269 })
1270 .collect();
1271
1272 let path = format!("screenshot_{timestamp}_depth.png");
1273 if let Err(e) = write_png(
1274 &path, &encoded, w as u32, h as u32,
1275 png::ColorType::Grayscale, png::BitDepth::Sixteen, png::Compression::High
1276 ) {
1277 error!("depth screenshot failed: {e}");
1278 } else {
1279 info!("depth screenshot saved to {path} (min={min:.4}, max={max:.4})");
1280 }
1281 } else {
1282 let mut rgb = vec![0u8; w * h * 3];
1283 self.context.read_pixels(Some(&mut rgb), None, rect)
1284 .map_err(MjViewerError::ContextError)?;
1285
1286 // OpenGL reads bottom-up; flip for top-down PNG row order.
1287 flip_image_vertically(&mut rgb, h, w * 3);
1288
1289 let path = format!("screenshot_{timestamp}.png");
1290 if let Err(e) = write_png(
1291 &path, &rgb, w as u32, h as u32,
1292 png::ColorType::Rgb, png::BitDepth::Eight, png::Compression::High
1293 ) {
1294 error!("screenshot failed: {e}");
1295 } else {
1296 info!("screenshot saved to {path}");
1297 }
1298 }
1299 Ok(())
1300 }
1301
1302 fn update_smooth_fps(&mut self) {
1303 let elapsed = self.fps_timer.elapsed();
1304
1305 let fps = if elapsed.is_zero() {
1306 self.fps_smooth
1307 } else {
1308 1.0 / elapsed.as_secs_f64()
1309 };
1310
1311 self.fps_timer = Instant::now();
1312 self.fps_smooth += FPS_SMOOTHING_FACTOR * (fps - self.fps_smooth);
1313 }
1314
1315 /// Creates the render context for the window surface `gl_surface`.
1316 ///
1317 /// # Safety
1318 /// The OpenGL context of `gl_surface` must be current.
1319 unsafe fn make_render_context(model: &MjModel, gl_surface: &Surface<WindowSurface>) -> MjrContext {
1320 // SAFETY: the caller guarantees the current OpenGL context.
1321 let mut context = unsafe { MjrContext::new(model) };
1322
1323 context.set_window_doublebuffer(!gl_surface.is_single_buffered());
1324 context
1325 }
1326
1327 /// Updates the scene and draws it to the display.
1328 fn update_scene(&mut self) -> Result<(), MjViewerError> {
1329 {
1330 let mut lock = self.shared_state.lock_unpoison();
1331 let ViewerSharedState {
1332 data_passive, pert, user_scene,
1333 texture_reupload_pending, mesh_reupload_pending,
1334 hfield_reupload_pending, model_reloaded, ..
1335 } = lock.deref_mut();
1336
1337 // Recreate scene when the model changes
1338 if *model_reloaded {
1339 debug!("the model changed, rebuilding the viewer scene and the render context");
1340 let new_model = data_passive.model();
1341 let ngeom = new_model.ffi().ngeom as usize;
1342 let max_user_geom = user_scene.maxgeom() as usize;
1343 self.scene = MjvScene::new(
1344 new_model,
1345 ngeom + max_user_geom + EXTRA_SCENE_GEOM_SPACE
1346 );
1347
1348 // Reset to a free camera: a tracking or fixed camera may reference a body
1349 // or camera ID that does not exist in the new model.
1350 self.camera = MjvCamera::new_free(new_model);
1351 // Recreate the rendering context so that GPU resources (textures,
1352 // meshes, heightfields, skins) match the new model.
1353 let gl_surface = &self.adapter.state.as_ref().unwrap().gl_surface;
1354 // SAFETY: the GL context was made current in render() before this call.
1355 self.context = unsafe { Self::make_render_context(new_model, gl_surface) };
1356 self.ncam = new_model.ffi().ncam;
1357
1358 #[cfg(feature = "viewer-ui")]
1359 self.ui.reset_to_model(new_model);
1360
1361 // reload_model already cleared the pending re-upload sets.
1362
1363 // The scene has been recreated after a model has been changed.
1364 // Thus, the scene is now permitted to be used again.
1365 *model_reloaded = false;
1366 }
1367
1368 // Update and render the scene from the MjData state
1369 self.scene.update(data_passive, &self.opt, pert, &mut self.camera);
1370
1371 // Upload pending assets.
1372 for id in std::mem::take(texture_reupload_pending) {
1373 self.context.upload_texture(data_passive.model(), id)?;
1374 }
1375 for id in std::mem::take(mesh_reupload_pending) {
1376 self.context.upload_mesh(data_passive.model(), id)?;
1377 }
1378 for id in std::mem::take(hfield_reupload_pending) {
1379 self.context.upload_hfield(data_passive.model(), id)?;
1380 }
1381
1382 // Draw geoms drawn through the user scene.
1383 sync_geoms(user_scene, &mut self.scene)?;
1384 }
1385 self.scene.render(&self.rect_full, &self.context);
1386 Ok(())
1387 }
1388
1389 /// Draws the user menu
1390 fn update_menus(&mut self) {
1391 let rectangle_from_ui = self.rect_view;
1392 let rectangle_full = self.rect_full;
1393
1394 // Overlay section
1395 if self.status.contains(ViewerStatusBit::HELP) { // Help
1396 self.context.overlay(
1397 MjtFont::mjFONT_NORMAL, MjtGridPos::mjGRID_TOPLEFT,
1398 rectangle_from_ui,
1399 HELP_MENU_TITLES,
1400 Some(HELP_MENU_VALUES)
1401 );
1402 }
1403
1404 // Read later-required information and then drop the mutex lock
1405 let (
1406 time,
1407 memory_pct,
1408 mut total_memory,
1409 realtime_factor
1410 ) = {
1411 let state_lock = self.shared_state.lock_unpoison();
1412 let data_lock = &state_lock.data_passive;
1413 let memory_total = data_lock.narena().max(1) as f64;
1414 (
1415 data_lock.time(),
1416 100.0 * data_lock.maxuse_arena() as f64 / memory_total, memory_total,
1417 state_lock.realtime_factor_smooth
1418 )
1419 };
1420
1421 if self.status.contains(ViewerStatusBit::INFO) { // Info
1422 self.update_smooth_fps();
1423
1424 // Overlay headers
1425 let headers = concat!(
1426 "FPS\n",
1427 "Time\n",
1428 "Memory\n",
1429 "Realtime factor"
1430 );
1431
1432 // Calculate the amount of memory used and represent with SI units.
1433 let mut memory_unit = ' ';
1434 if total_memory > 1e6 {
1435 total_memory /= 1e6;
1436 memory_unit = 'M';
1437 } else if total_memory > 1e3 {
1438 total_memory /= 1e3;
1439 memory_unit = 'k';
1440 }
1441
1442 // Format values of the overlay.
1443 let values = format!(
1444 concat!(
1445 "{:.1}\n",
1446 "{:.1}\n",
1447 "{:.1} % out of {:.1} {}\n",
1448 "{:.1} %"
1449 ),
1450 self.fps_smooth,
1451 time,
1452 memory_pct, total_memory, memory_unit,
1453 realtime_factor * 100.0
1454 );
1455
1456 self.context.overlay(
1457 MjtFont::mjFONT_NORMAL,
1458 MjtGridPos::mjGRID_BOTTOMLEFT,
1459 rectangle_from_ui,
1460 headers,
1461 Some(&values)
1462 );
1463 }
1464
1465 // Check for slowdowns
1466 if self.status.contains(ViewerStatusBit::WARN_REALTIME)
1467 && (realtime_factor - 1.0).abs() > REALTIME_FACTOR_DISPLAY_THRESHOLD
1468 {
1469 self.context.overlay(
1470 MjtFont::mjFONT_BIG,
1471 MjtGridPos::mjGRID_BOTTOMRIGHT,
1472 rectangle_full,
1473 &format!("Realtime factor: {:.1} %", realtime_factor * 100.0),
1474 None
1475 );
1476 }
1477 }
1478
1479 /// Gains scoped access to [`egui::Context`], which is part of the UI,
1480 /// for dealing with custom initialization (e.g., loading in images).
1481 #[cfg(feature = "viewer-ui")]
1482 pub fn with_ui_egui_ctx<F>(&mut self, once_fn: F)
1483 where F: FnOnce(&egui::Context)
1484 {
1485 self.ui.with_egui_ctx(once_fn);
1486 }
1487
1488 /// Builds the user UI and processes its internally raised events.
1489 #[cfg(feature = "viewer-ui")]
1490 fn process_user_ui(&mut self) -> Result<(), MjViewerError> {
1491 use crate::viewer::ui::UiEvent;
1492 let RenderBaseGlState {window,
1493 gl_context,
1494 gl_surface
1495 } = &self.adapter.state.as_ref().unwrap();
1496
1497 let inner_size = window.inner_size();
1498 let left = self.ui.process(
1499 window, &mut self.status,
1500 &mut self.scene, &mut self.opt,
1501 &mut self.camera, &self.shared_state, &self.running_flag
1502 );
1503
1504 // Make context current again after user's custom UI callbacks possibly changing the current context.
1505 gl_context.make_current(gl_surface).map_err(MjViewerError::GlutinError)?;
1506
1507 // Adjust the viewport so MuJoCo doesn't draw over the UI
1508 self.rect_view.left = left as i32;
1509 self.rect_view.width = inner_size.width as i32;
1510
1511 // Process events made in the user UI
1512 while let Some(event) = self.ui.drain_events() {
1513 use UiEvent::*;
1514 match event {
1515 Close => self.request_close(),
1516 Fullscreen => self.toggle_full_screen(),
1517 ResetSimulation => {
1518 let mut lock = self.shared_state.lock_unpoison();
1519 lock.data_passive.reset();
1520 lock.data_passive.forward();
1521 },
1522 AlignCamera => {
1523 self.camera = MjvCamera::new_free(self.shared_state.lock_unpoison().data_passive.model());
1524 },
1525 VSyncToggle => {
1526 self.update_vsync();
1527 },
1528 Screenshot { viewport_only, depth } => {
1529 self.screenshot_pending = Some((viewport_only, depth));
1530 }
1531 }
1532 }
1533
1534 Ok(())
1535 }
1536
1537 /// Reads the state of requested vsync setting and makes appropriate calls to [`glutin`].
1538 fn update_vsync(&mut self) {
1539 let RenderBaseGlState {
1540 gl_surface, gl_context, ..
1541 } = &self.adapter.state.as_ref().unwrap();
1542
1543 if self.status.contains(ViewerStatusBit::VSYNC) {
1544 if let Err(e) = gl_surface.set_swap_interval(
1545 gl_context, glutin::surface::SwapInterval::Wait(NonZero::<u32>::MIN)
1546 ) {
1547 warn!("failed to enable vsync: {e}");
1548 self.status.set(ViewerStatusBit::VSYNC, false);
1549 }
1550 } else {
1551 if let Err(e) = gl_surface.set_swap_interval(
1552 gl_context, glutin::surface::SwapInterval::DontWait
1553 ) {
1554 warn!("failed to disable vsync: {e}");
1555 self.status.set(ViewerStatusBit::VSYNC, true);
1556 }
1557 }
1558 }
1559
1560 /// Updates the dimensions of the rectangles defining the dimensions of
1561 /// the user interface, as well as the actual scene viewer.
1562 fn update_rectangles(&mut self, viewport_size: (i32, i32)) {
1563 // The scene (middle) rectangle
1564 self.rect_view.width = viewport_size.0;
1565 self.rect_view.height = viewport_size.1;
1566
1567 self.rect_full.width = viewport_size.0;
1568 self.rect_full.height = viewport_size.1;
1569 }
1570
1571 /// Processes user input events.
1572 fn process_events(&mut self) {
1573 self.event_loop.pump_app_events(Some(Duration::ZERO), &mut self.adapter);
1574 while let Some(window_event) = self.adapter.queue.pop_front() {
1575 #[cfg(feature = "viewer-ui")]
1576 {
1577 let window: &winit::window::Window = &self.adapter.state.as_ref().unwrap().window;
1578 self.ui.handle_events(window, &window_event);
1579
1580 // if the UI has an active input focus, ignore all keyboard events
1581 if let WindowEvent::KeyboardInput { .. } = &window_event
1582 && self.ui.is_focused()
1583 {
1584 continue;
1585 }
1586 }
1587
1588 match window_event {
1589 WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers,
1590 WindowEvent::MouseInput {state, button, .. } => {
1591 let is_pressed = state == ElementState::Pressed;
1592
1593 #[cfg(feature = "viewer-ui")]
1594 if is_pressed {
1595 // A press outside the UI windows (scene or side panel) takes the
1596 // selection away from them.
1597 if !self.ui.is_window_covered() {
1598 self.ui.deselect_windows();
1599 }
1600
1601 if self.ui.is_covered() {
1602 continue;
1603 }
1604 }
1605
1606 let index = match button {
1607 MouseButton::Left => {
1608 self.process_left_click(state);
1609 ButtonsPressed::LEFT
1610 },
1611 MouseButton::Middle => ButtonsPressed::MIDDLE,
1612 MouseButton::Right => ButtonsPressed::RIGHT,
1613 _ => continue
1614 };
1615
1616 self.buttons_pressed.set(index, is_pressed);
1617 }
1618
1619 WindowEvent::CursorMoved { position, .. } => {
1620 let PhysicalPosition { x, y } = position;
1621
1622 // The UI might not be detected as covered as dragging can happen slightly outside
1623 // of a (popup) window. This might seem like an ad-hoc solution, but is at the time the
1624 // shortest and most efficient one.
1625 #[cfg(feature = "viewer-ui")]
1626 if self.ui.is_dragged() {
1627 continue;
1628 }
1629
1630 self.process_cursor_pos(x, y);
1631 }
1632
1633 // Request a close on Ctrl+Q (or open exit modal under `viewer-ui` feature enabled).
1634 WindowEvent::KeyboardInput {
1635 event: KeyEvent {
1636 physical_key: PhysicalKey::Code(KeyCode::KeyQ),
1637 state: ElementState::Pressed, ..
1638 }, ..
1639 } if self.modifiers.state().control_key() => {
1640 self.request_close();
1641 }
1642
1643 // Request a close on window close (or open exit modal under `viewer-ui` feature enabled).
1644 WindowEvent::CloseRequested => {
1645 self.request_close();
1646 }
1647
1648 // Free the camera from tracking.
1649 WindowEvent::KeyboardInput {
1650 event: KeyEvent {
1651 physical_key: PhysicalKey::Code(KeyCode::Escape),
1652 state: ElementState::Pressed, ..
1653 }, ..
1654 } => {
1655 self.camera.free();
1656 }
1657
1658 // Toggle help menu
1659 WindowEvent::KeyboardInput {
1660 event: KeyEvent {
1661 physical_key: PhysicalKey::Code(KeyCode::F1),
1662 state: ElementState::Pressed, ..
1663 }, ..
1664 } => {
1665 self.status.toggle(ViewerStatusBit::HELP);
1666 }
1667
1668 // Toggle info menu
1669 WindowEvent::KeyboardInput {
1670 event: KeyEvent {
1671 physical_key: PhysicalKey::Code(KeyCode::F2),
1672 state: ElementState::Pressed, ..
1673 }, ..
1674 } => {
1675 self.status.toggle(ViewerStatusBit::INFO);
1676 }
1677
1678 // Toggle VSync
1679 WindowEvent::KeyboardInput {
1680 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::F3), state: ElementState::Pressed, ..},
1681 ..
1682 } => {
1683 self.status.toggle(ViewerStatusBit::VSYNC);
1684 self.update_vsync();
1685 }
1686
1687 // Non-realtime warnings
1688 WindowEvent::KeyboardInput {
1689 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::F4), state: ElementState::Pressed, ..},
1690 ..
1691 } => {
1692 self.status.toggle(ViewerStatusBit::WARN_REALTIME);
1693 }
1694
1695 // Full screen
1696 WindowEvent::KeyboardInput {
1697 event: KeyEvent {
1698 physical_key: PhysicalKey::Code(KeyCode::F5),
1699 state: ElementState::Pressed, ..
1700 }, ..
1701 } => {
1702 self.toggle_full_screen();
1703 }
1704
1705 // Reset the simulation (the data).
1706 WindowEvent::KeyboardInput {
1707 event: KeyEvent {
1708 physical_key: PhysicalKey::Code(KeyCode::Backspace),
1709 state: ElementState::Pressed, ..
1710 }, ..
1711 } => {
1712 let mut lock = self.shared_state.lock_unpoison();
1713 lock.data_passive.reset();
1714 lock.data_passive.forward();
1715 }
1716
1717 // Cycle to the next camera
1718 WindowEvent::KeyboardInput {
1719 event: KeyEvent {
1720 physical_key: PhysicalKey::Code(KeyCode::BracketRight),
1721 state: ElementState::Pressed, ..
1722 }, ..
1723 } => {
1724 self.cycle_camera(1);
1725 }
1726
1727 // Cycle to the previous camera
1728 WindowEvent::KeyboardInput {
1729 event: KeyEvent {
1730 physical_key: PhysicalKey::Code(KeyCode::BracketLeft),
1731 state: ElementState::Pressed, ..
1732 }, ..
1733 } => {
1734 self.cycle_camera(-1);
1735 }
1736
1737 // Toggles camera visualization.
1738 WindowEvent::KeyboardInput {
1739 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyC), state: ElementState::Pressed, ..},
1740 ..
1741 } => {
1742 if self.modifiers.state().control_key() { // Control + C is reserved for copy
1743 continue;
1744 }
1745
1746 self.toggle_opt_flag(MjtVisFlag::mjVIS_CAMERA);
1747 }
1748
1749 WindowEvent::KeyboardInput {
1750 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyU), state: ElementState::Pressed, ..},
1751 ..
1752 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_ACTUATOR),
1753
1754 WindowEvent::KeyboardInput {
1755 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyJ), state: ElementState::Pressed, ..},
1756 ..
1757 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_JOINT),
1758
1759 WindowEvent::KeyboardInput {
1760 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyM), state: ElementState::Pressed, ..},
1761 ..
1762 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_COM),
1763
1764 WindowEvent::KeyboardInput {
1765 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyH), state: ElementState::Pressed, ..},
1766 ..
1767 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_CONVEXHULL),
1768
1769 WindowEvent::KeyboardInput {
1770 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyZ), state: ElementState::Pressed, ..},
1771 ..
1772 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_LIGHT),
1773
1774 WindowEvent::KeyboardInput {
1775 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyT), state: ElementState::Pressed, ..},
1776 ..
1777 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_TRANSPARENT),
1778
1779 WindowEvent::KeyboardInput {
1780 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyI), state: ElementState::Pressed, ..},
1781 ..
1782 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_INERTIA),
1783
1784 WindowEvent::KeyboardInput {
1785 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyE), state: ElementState::Pressed, ..},
1786 ..
1787 } => self.toggle_opt_flag(MjtVisFlag::mjVIS_CONSTRAINT),
1788
1789 #[cfg(feature = "viewer-ui")]
1790 WindowEvent::KeyboardInput {
1791 event: KeyEvent {physical_key: PhysicalKey::Code(KeyCode::KeyX), state: ElementState::Pressed, ..},
1792 ..
1793 } => self.status.toggle(ViewerStatusBit::UI),
1794
1795 // Zoom in/out
1796 WindowEvent::MouseWheel {delta, ..} => {
1797 #[cfg(feature = "viewer-ui")]
1798 if self.ui.is_covered() {
1799 continue;
1800 }
1801
1802 let value = match delta {
1803 MouseScrollDelta::LineDelta(_, down) => down as f64,
1804 MouseScrollDelta::PixelDelta(PhysicalPosition {y, ..}) => y * TOUCH_BAR_ZOOM_FACTOR
1805 };
1806 self.process_scroll(value);
1807 }
1808
1809 _ => {} // ignore other events
1810 }
1811 }
1812 }
1813
1814 /// Toggles visualization options.
1815 fn toggle_opt_flag(&mut self, flag: MjtVisFlag) {
1816 let index = flag as usize;
1817 self.opt.flags[index] = 1 - self.opt.flags[index];
1818 }
1819
1820 /// Cycle MJCF defined cameras.
1821 fn cycle_camera(&mut self, direction: i32) {
1822 let ncam = self.ncam;
1823 if ncam == 0 { // No cameras, ignore.
1824 return;
1825 }
1826
1827 self.camera.fix((self.camera.fixedcamid + direction).rem_euclid(ncam as i32) as usize);
1828 }
1829
1830 /// Toggles full screen mode.
1831 fn toggle_full_screen(&mut self) {
1832 let window = &self.adapter.state.as_ref().unwrap().window;
1833 if window.fullscreen().is_some() {
1834 window.set_fullscreen(None);
1835 }
1836 else {
1837 window.set_fullscreen(Some(Fullscreen::Borderless(None)));
1838 }
1839 }
1840
1841 /// Processes scrolling events.
1842 fn process_scroll(&mut self, change: f64) {
1843 self.camera.move_(
1844 MjtMouse::mjMOUSE_ZOOM,
1845 self.shared_state.lock_unpoison().data_passive.model(),
1846 0.0, -0.05 * change
1847 );
1848 }
1849
1850 /// Processes camera and perturbation movements.
1851 fn process_cursor_pos(&mut self, x: f64, y: f64) {
1852 self.raw_cursor_position = (x, y);
1853 // Calculate the change in mouse position since last call
1854 let dx = x - self.last_x;
1855 let dy = y - self.last_y;
1856 self.last_x = x;
1857 self.last_y = y;
1858 let window = &self.adapter.state.as_ref().unwrap().window;
1859 let modifiers = &self.modifiers.state();
1860 let buttons = &self.buttons_pressed;
1861 let shift = modifiers.shift_key();
1862
1863 // Check mouse presses and move the camera if any of them is pressed
1864 let action;
1865 let height = window.inner_size().height as f64;
1866
1867 let mut lock = self.shared_state.lock_unpoison();
1868 let ViewerSharedState {data_passive, pert, ..} = lock.deref_mut();
1869 if buttons.contains(ButtonsPressed::LEFT) {
1870 if pert.active == MjtPertBit::mjPERT_TRANSLATE as i32 {
1871 action = if shift {MjtMouse::mjMOUSE_MOVE_H} else {MjtMouse::mjMOUSE_MOVE_V};
1872 }
1873 else {
1874 action = if shift {MjtMouse::mjMOUSE_ROTATE_H} else {MjtMouse::mjMOUSE_ROTATE_V};
1875 }
1876 }
1877 else if buttons.contains(ButtonsPressed::RIGHT) {
1878 action = if shift {MjtMouse::mjMOUSE_MOVE_H} else {MjtMouse::mjMOUSE_MOVE_V};
1879 }
1880 else if buttons.contains(ButtonsPressed::MIDDLE) {
1881 action = MjtMouse::mjMOUSE_ZOOM;
1882 }
1883 else {
1884 return; // If buttons aren't pressed, ignore.
1885 }
1886
1887 // When the perturbation isn't active, move the camera
1888 if pert.active == 0 {
1889 self.camera.move_(
1890 action,
1891 data_passive.model(),
1892 dx / height, dy / height
1893 );
1894 }
1895 else { // When the perturbation is active, move apply the perturbation.
1896 pert.move_(data_passive, action, dx / height, dy / height, &self.scene);
1897 }
1898 }
1899
1900 /// Processes left clicks and double left clicks.
1901 fn process_left_click(&mut self, state: ElementState) {
1902 let modifier_state = self.modifiers.state();
1903 let mut lock = self.shared_state.lock_unpoison();
1904 let ViewerSharedState {data_passive, pert, model_reloaded, ..} = lock.deref_mut();
1905
1906 // The render thread skips the mutex lock for part of its work, so a model reload defers
1907 // every operation until the next sync in update_scene.
1908 if *model_reloaded {
1909 return;
1910 }
1911
1912 match state {
1913 ElementState::Pressed => {
1914 // Clicking and holding applies perturbation
1915 if pert.select > 0 && modifier_state.control_key() {
1916 let type_ = if modifier_state.alt_key() {
1917 MjtPertBit::mjPERT_TRANSLATE
1918 } else {
1919 MjtPertBit::mjPERT_ROTATE
1920 };
1921 pert.start(type_, data_passive, &self.scene);
1922 }
1923
1924 // Double click detection
1925 if self.last_bnt_press_time.elapsed().as_millis() < DOUBLE_CLICK_WINDOW_MS {
1926 let cp = self.raw_cursor_position;
1927 let x = cp.0;
1928 let y = self.rect_full.height as f64 - cp.1;
1929
1930 // Obtain the selection
1931 let rect = &self.rect_full;
1932 let sel = self.scene.find_selection(
1933 data_passive, &self.opt,
1934 rect.width as MjtNum / rect.height as MjtNum,
1935 (x - rect.left as MjtNum) / rect.width as MjtNum,
1936 (y - rect.bottom as MjtNum) / rect.height as MjtNum
1937 );
1938
1939 // Either track, do nothing, change the look-at point or change the selected target.
1940 let (ctrl, alt) = (modifier_state.control_key(), modifier_state.alt_key());
1941 if let Some(body_id) = sel.body_id {
1942 match (ctrl, alt) {
1943 (true, true) => self.camera.track(body_id), // ctrl + alt
1944 (true, false) => {} // ctrl
1945 (false, true) => self.camera.lookat = sel.point, // alt
1946 (false, false) => { // no ctrl, no alt
1947 pert.select = body_id as i32;
1948 pert.flexselect = sel.flex_id.map(|v| v as i32).unwrap_or(-1);
1949 pert.skinselect = sel.skin_id.map(|v| v as i32).unwrap_or(-1);
1950 pert.active = 0;
1951 pert.update_local_pos(&sel.point, data_passive);
1952 },
1953 }
1954 }
1955 // Reset perturbation.
1956 else if !(ctrl || alt) {
1957 pert.select = -1;
1958 pert.flexselect = -1;
1959 pert.skinselect = -1;
1960 pert.active = 0;
1961 }
1962 }
1963 self.last_bnt_press_time = Instant::now();
1964 },
1965 ElementState::Released => {
1966 // Clear perturbation when left click is released.
1967 pert.active = 0;
1968 },
1969 };
1970 }
1971}
1972
1973/// Releases OpenGL resources (rendering context and egui painter) while the
1974/// GL context is still current.
1975impl Drop for MjViewer {
1976 fn drop(&mut self) {
1977 // Ensure the GL context is current before the implicit field drops so that
1978 // MjrContext::drop (which calls mjr_freeContext) can properly free OpenGL resources.
1979 if let Some(ref state) = self.adapter.state {
1980 let _ = state.gl_context.make_current(&state.gl_surface);
1981 }
1982
1983 // Release egui painter GL resources while the context is still current.
1984 #[cfg(feature = "viewer-ui")]
1985 self.ui.destroy_gl();
1986 }
1987}
1988
1989
1990/// Builder for [`MjViewer`].
1991/// ### Default settings:
1992/// - `window_name`: MuJoCo Rust Viewer (MuJoCo \<MuJoCo version here\>)
1993/// - `max_user_geoms`: 0
1994/// - `vsync`: false
1995/// - `warn_non_realtime`: false
1996///
1997#[derive(Debug, Clone)]
1998pub struct MjViewerBuilder {
1999 /// The name shown on the window decoration.
2000 window_name: Cow<'static, str>,
2001 /// Maximum number of geoms that can be given by the user for custom visualization.
2002 max_user_geoms: usize,
2003 /// Start the viewer with vertical synchronization. This should be used only if rendering
2004 /// and simulation are separated by threads and you are ok with [`MjViewer::render`]
2005 /// blocking to achieve the correct refresh rate (of your monitor).
2006 vsync: bool,
2007
2008 /// Start the viewer with warnings enabled for non-realtime synchronization.
2009 /// When this is enabled and the simulation state isn't synced in realtime, an overlay will be displayed
2010 /// in the bottom right corner indicating the realtime percentage.
2011 /// The warning will only be shown if the deviation from realtime is larger than 2 %.
2012 warn_non_realtime: bool,
2013}
2014
2015impl MjViewerBuilder {
2016 builder_setters! {
2017 window_name: S where S: Into<Cow<'static, str>>; "text shown in the title of the window.";
2018 max_user_geoms: usize; "maximum number of geoms that can be drawn by the user in addition to the regular geoms.";
2019 vsync: bool; "enable vertical synchronization by default.";
2020 warn_non_realtime: bool; "enable showing an overlay when the simulation state isn't synced in realtime (deviation larger than 2 %).";
2021 }
2022}
2023
2024impl MjViewerBuilder {
2025 /// Creates a [`MjViewerBuilder`] with default settings.
2026 pub fn new() -> Self {
2027 Self {
2028 window_name: Cow::Owned(format!("MuJoCo Rust Viewer (MuJoCo {})", mujoco_version())),
2029 max_user_geoms: 0, vsync: false, warn_non_realtime: false,
2030 }
2031 }
2032
2033 /// Builds a [`MjViewer`] with the configured options.
2034 /// # Returns
2035 /// On success, returns [`Ok`] variant containing the [`MjViewer`].
2036 /// # Errors
2037 /// - [`MjViewerError::EventLoopError`] if the event loop fails to initialize.
2038 /// - [`MjViewerError::GlInitFailed`] if OpenGL / window initialization fails.
2039 /// - [`MjViewerError::GlutinError`] if a glutin operation fails.
2040 /// - [`MjViewerError::PainterInitError`] if the UI painter fails to initialize
2041 /// (feature `viewer-ui`).
2042 /// # Panics
2043 /// Panics if the GL display reports no usable configuration.
2044 pub fn build_passive(&self, model: &MjModel) -> Result<MjViewer, MjViewerError> {
2045 let (w, h) = MJ_VIEWER_DEFAULT_SIZE_PX;
2046 let mut event_loop = EventLoop::new().map_err(MjViewerError::EventLoopError)?;
2047 let adapter = RenderBase::new(
2048 w, h,
2049 self.window_name.to_string(),
2050 &mut event_loop,
2051 true // process events
2052 )?;
2053
2054 // Initialize the OpenGL related things
2055 let RenderBaseGlState {
2056 gl_context,
2057 gl_surface,
2058 #[cfg(feature = "viewer-ui")] window,
2059 ..
2060 } = adapter.state.as_ref().unwrap();
2061 gl_context.make_current(gl_surface).map_err(MjViewerError::GlutinError)?;
2062
2063 // Configure vertical synchronization
2064 if self.vsync {
2065 gl_surface.set_swap_interval(
2066 gl_context,
2067 glutin::surface::SwapInterval::Wait(NonZero::<u32>::MIN)
2068 ).map_err(MjViewerError::GlutinError)?;
2069 } else {
2070 gl_surface.set_swap_interval(gl_context, glutin::surface::SwapInterval::DontWait).map_err(
2071 MjViewerError::GlutinError
2072 )?;
2073 }
2074
2075 event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
2076
2077 let ngeom = model.ffi().ngeom as usize;
2078 let scene = MjvScene::new(model, ngeom + self.max_user_geoms + EXTRA_SCENE_GEOM_SPACE);
2079 // SAFETY: The OpenGL context was made current above via gl_surface.
2080 let context = unsafe { MjViewer::make_render_context(model, gl_surface) };
2081 let camera = MjvCamera::new_free(model);
2082
2083 // Tracking of changes made between syncs
2084 let shared_state = Arc::new(Mutex::new(ViewerSharedState::new(model, self.max_user_geoms)));
2085 let running_flag = shared_state.lock_unpoison().running.clone();
2086
2087 // OpenGL context proxy
2088 #[cfg(feature = "viewer-ui")]
2089 let display = gl_surface.display();
2090 #[cfg(feature = "viewer-ui")]
2091 let get_addr = |s: &str| display.get_proc_address(
2092 &CString::new(s).unwrap()
2093 );
2094 // SAFETY: the glow::Context is constructed from a loader function backed by the
2095 // current glutin Display, which provides valid OpenGL proc addresses.
2096 #[cfg(feature = "viewer-ui")]
2097 let gl = unsafe { Arc::new(glow::Context::from_loader_function(get_addr)) };
2098
2099 // User interface
2100 #[cfg(feature = "viewer-ui")]
2101 let ui = ui::ViewerUI::new(model, window, &gl)?;
2102 let mut status = cfg_select! {
2103 feature = "viewer-ui" => ViewerStatusBit::UI,
2104 _ => ViewerStatusBit::HELP,
2105 };
2106
2107 status.set(ViewerStatusBit::VSYNC, self.vsync);
2108 status.set(ViewerStatusBit::WARN_REALTIME, self.warn_non_realtime);
2109
2110 let size = adapter.state.as_ref().unwrap().window.inner_size();
2111 debug!("opened the viewer window \"{}\" ({}x{})", self.window_name, size.width, size.height);
2112
2113 Ok(MjViewer {
2114 scene,
2115 context,
2116 camera,
2117 opt: MjvOption::default(),
2118 ncam: model.ffi().ncam,
2119 shared_state,
2120 running_flag,
2121 last_x: 0.0,
2122 last_y: 0.0,
2123 last_bnt_press_time: Instant::now(),
2124 fps_timer: Instant::now(),
2125 fps_smooth: 60.0,
2126 rect_view: MjrRectangle::default(),
2127 rect_full: MjrRectangle::default(),
2128 adapter,
2129 event_loop,
2130 modifiers: Modifiers::default(),
2131 buttons_pressed: ButtonsPressed::empty(),
2132 raw_cursor_position: (0.0, 0.0),
2133 #[cfg(feature = "viewer-ui")] ui,
2134 status,
2135 screenshot_pending: None,
2136 #[cfg(feature = "viewer-ui")] gl,
2137 })
2138 }
2139}
2140
2141/// Delegates to [`MjViewerBuilder::new`].
2142impl Default for MjViewerBuilder {
2143 fn default() -> Self {
2144 MjViewerBuilder::new()
2145 }
2146}
2147
2148bitflags! {
2149 /// Internal bit-flags that track the on-screen overlays, the side panel and the vsync mode.
2150 #[derive(Debug)]
2151 struct ViewerStatusBit: u8 {
2152 const HELP = 1 << 0;
2153 const VSYNC = 1 << 1;
2154 const INFO = 1 << 2;
2155 const WARN_REALTIME = 1 << 3;
2156 #[cfg(feature = "viewer-ui")] const UI = 1 << 4;
2157 }
2158}
2159
2160bitflags! {
2161 /// Boolean flags for tracking button press events.
2162 #[derive(Debug)]
2163 struct ButtonsPressed: u8 {
2164 const LEFT = 1 << 0;
2165 const MIDDLE = 1 << 1;
2166 const RIGHT = 1 << 2;
2167 }
2168}
2169
2170
2171#[cfg(test)]
2172mod tests {
2173 use crate::wrappers::{MjSpec, SpecItem, mj_editing::MjtEmbed};
2174
2175 use super::*;
2176
2177 /// A model with a free joint, whose `qpos0` holds a unit quaternion.
2178 const MODEL_FREE: &str =
2179 "<mujoco><worldbody><body><freejoint/><geom size=\"0.1\"/></body></worldbody></mujoco>";
2180 /// A model with one hinge joint and a gravity that `MODEL_FREE` does not share.
2181 const MODEL_HINGE: &str =
2182 "<mujoco><option gravity=\"0 0 -1\"/><worldbody><body><joint type=\"hinge\"/>\
2183 <geom size=\"0.1\"/></body></worldbody></mujoco>";
2184
2185 #[test]
2186 fn test_update_texture_from_by_reference() {
2187 const PNG_PATH: &str = "_TMP_viewer_byref_texture.png";
2188 std::fs::write(PNG_PATH, crate::util::testing::PNG_1X1).unwrap();
2189
2190 let mut spec = MjSpec::new();
2191 spec.add_texture()
2192 .with_file(PNG_PATH)
2193 .with_name("texture")
2194 .set_embed(MjtEmbed::mjEMBED_FALSE);
2195
2196 let model = spec.compile();
2197 std::fs::remove_file(PNG_PATH).unwrap();
2198 let model = model.unwrap();
2199 assert_eq!(model.tex_adr()[0], -1);
2200
2201 let mut state = ViewerSharedState::new(&model, 0);
2202 state.update_texture_from(&model, 0).unwrap();
2203 assert!(state.texture_reupload_pending.contains(&0));
2204 }
2205
2206 /// A reload replaces the passive model, so the merge shadow must describe the new one. A stale
2207 /// shadow makes the next `sync_model` overwrite the caller's own edit.
2208 #[test]
2209 fn test_reload_refreshes_the_model_merge_shadow() {
2210 let mut model_hinge = MjModel::from_xml_string(MODEL_HINGE).unwrap();
2211 let mut model_free = MjModel::from_xml_string(MODEL_FREE).unwrap();
2212 let mut state = ViewerSharedState::new(&model_hinge, 0);
2213
2214 // Fill the shadow with the first model's own gravity, which the second model does not
2215 // share. Without this the shadow holds MuJoCo's defaults, which already match.
2216 state.sync_model(&mut model_hinge);
2217
2218 // A data sync reloads the passive model, and it never merges the options itself.
2219 {
2220 let mut data_free = MjData::new(&model_free);
2221 state.sync_data(&mut data_free);
2222 }
2223
2224 // The caller edits its own model after the reload. The viewer changed nothing since, so
2225 // the edit must reach the passive model instead of being overwritten by it.
2226 let expected = -5.0;
2227 model_free.opt_mut().gravity[2] = expected;
2228 state.sync_model(&mut model_free);
2229 assert_eq!(
2230 model_free.ffi().opt.gravity[2], expected,
2231 "the caller's edit must survive the first sync_model after a reload"
2232 );
2233 assert_eq!(
2234 state.data_passive.model().ffi().opt.gravity[2], expected,
2235 "the edit must also reach the passive model"
2236 );
2237 }
2238}