Skip to main content

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}