1use crate::wrappers::mj_model::traits::ModelType;
3use crate::wrappers::mj_visualization::MjvScene;
4use crate::wrappers::mj_rendering::MjrContext;
5
6#[cfg(target_os = "linux")]
7use crate::renderer::egl::GlStateEgl;
8
9use crate::vis_common::{sync_geoms, flip_image_vertically, write_png};
10use crate::builder_setters;
11use crate::prelude::*;
12
13use bitflags::bitflags;
14#[cfg(all(feature = "renderer-winit-fallback", target_os = "linux"))]
15use log::warn;
16use log::debug;
17
18use std::io::{self, BufWriter, Write};
19use std::fmt::Display;
20use std::error::Error;
21use std::num::NonZero;
22use std::path::Path;
23use std::fs::File;
24
25pub use png;
27
28const DEPTH_U16_SCALE: f32 = u16::MAX as f32;
30
31#[cfg(feature = "renderer-winit-fallback")]
32mod universal;
33
34#[cfg(feature = "renderer-winit-fallback")]
35use universal::GlStateWinit;
36
37#[cfg(target_os = "linux")]
38mod egl;
39
40
41const EXTRA_INTERNAL_VISUAL_GEOMS: u32 = 100;
43
44fn model_near_far(model: &MjModel) -> (f32, f32) {
46 let map = &model.vis().map;
47 let extent = model.stat().extent as f32;
48 (map.znear * extent, map.zfar * extent)
49}
50
51
52#[derive(Debug)]
55#[allow(clippy::large_enum_variant)]
56pub(crate) enum GlState {
57 #[cfg(feature = "renderer-winit-fallback")] Winit(GlStateWinit),
58 #[cfg(target_os = "linux")] Egl(egl::GlStateEgl),
59}
60
61impl GlState {
62 pub(crate) fn new(width: NonZero<u32>, height: NonZero<u32>) -> Result<Self, RendererError> {
65 #[cfg(target_os = "linux")]
66 #[allow(unused_variables)]
67 let egl_err = match GlStateEgl::new(width, height) {
68 Ok(egl_state) => return Ok(Self::Egl(egl_state)),
69 Err(e) => e,
70 };
71
72 #[cfg(feature = "renderer-winit-fallback")]
73 match GlStateWinit::new(width, height) {
74 Ok(winit_state) => {
75 #[cfg(target_os = "linux")]
76 warn!("offscreen rendering is not available ({egl_err}), using an invisible window");
77 return Ok(Self::Winit(winit_state));
78 },
79 #[cfg(not(target_os = "linux"))]
80 Err(e) => {
81 return Err(e);
82 },
83
84 #[cfg(target_os = "linux")]
85 _ => {}
86 }
87
88 #[cfg(target_os = "linux")]
89 Err(RendererError::GlutinError(egl_err))
90 }
91
92 pub(crate) fn make_current(&self) -> glutin::error::Result<()> {
94 match self {
95 #[cfg(target_os = "linux")]
96 Self::Egl(egl_state) => egl_state.make_current(),
97 #[cfg(feature = "renderer-winit-fallback")]
98 Self::Winit(winit_state) => winit_state.make_current()
99 }
100 }
101}
102
103
104#[derive(Debug, Clone)]
106pub struct MjRendererBuilder {
107 width: u32,
108 height: u32,
109 num_visual_internal_geom: u32,
110 num_visual_user_geom: u32,
111 rgb: bool,
112 depth: bool,
113 png_compression: png::Compression,
114 font_scale: MjtFontScale,
115 camera: MjvCamera,
116 opts: MjvOption,
117}
118
119impl MjRendererBuilder {
120 pub fn new() -> Self {
134 Self {
135 width: 0, height: 0,
136 num_visual_internal_geom: EXTRA_INTERNAL_VISUAL_GEOMS, num_visual_user_geom: 0,
137 rgb: true, depth: false, png_compression: png::Compression::NoCompression,
138 font_scale: MjtFontScale::mjFONTSCALE_100,
139 camera: MjvCamera::default(), opts: MjvOption::default(),
140 }
141 }
142
143 builder_setters! {
144 width: u32; "
145image width.
146
147<div class=\"warning\">
148
149The width must be less or equal to the offscreen buffer width,
150which can be configured at the top of the model's XML like so:
151
152```xml
153<visual>
154 <global offwidth=\"1920\" .../>
155</visual>
156```
157
158</div>";
159
160 height: u32; "\
161image height.
162
163<div class=\"warning\">
164
165The height must be less or equal to the offscreen buffer height,
166which can be configured at the top of the model's XML like so:
167
168```xml
169<visual>
170 <global offheight=\"1080\" .../>
171</visual>
172```
173
174</div>";
175
176 num_visual_internal_geom: u32; "\
177 maximum number of additional visual-only internal geoms to allocate for.
178 Note that the total number of geoms in the internal scene will be
179 `model.ngeom` + `num_visual_internal_geom` + `num_visual_user_geom`.";
180
181 num_visual_user_geom: u32; "maximum number of additional visual-only user geoms (drawn by the user).";
182 rgb: bool; "RGB rendering enabled (true) or disabled (false).";
183 depth: bool; "depth rendering enabled (true) or disabled (false).";
184 png_compression: png::Compression; "PNG compression level used by [`MjRenderer::save_rgb`] and [`MjRenderer::save_depth`].";
185 font_scale: MjtFontScale; "font scale of drawn text (with [MjrContext]).";
186 camera: MjvCamera; "camera used for drawing.";
187 opts: MjvOption; "visualization options.";
188 }
189
190 pub fn build(self, model: &MjModel) -> Result<MjRenderer, RendererError> {
208 let mut height = self.height;
210 let mut width = self.width;
211 if width == 0 && height == 0 {
212 let global = &model.vis().global;
213 height = global.offheight as u32;
214 width = global.offwidth as u32;
215 }
216
217 let gl_state = GlState::new(
218 NonZero::new(width).ok_or(RendererError::ZeroDimension)?,
219 NonZero::new(height).ok_or(RendererError::ZeroDimension)?,
220 )?;
221
222 let mut context = unsafe { MjrContext::new(model) };
225 context.offscreen();
226 context.change_font(self.font_scale);
227
228 let extra_geom = self.num_visual_internal_geom as usize + self.num_visual_user_geom as usize;
229 let (near, far) = model_near_far(model);
230
231 let scene = MjvScene::new(
233 model,
234 model.ffi().ngeom as usize + extra_geom
235 );
236
237 let user_scene = MjvScene::new(
238 model,
239 self.num_visual_user_geom as usize
240 );
241
242 let renderer = MjRenderer {
244 scene, user_scene, context, camera: self.camera, option: self.opts,
245 flags: RendererFlags::empty(), rgb: None, depth: None,
246 width: width as usize, height: height as usize, gl_state,
247 png_compression: self.png_compression, font_scale: self.font_scale,
248 near, far, extra_geom,
249 } .with_rgb_rendering(self.rgb)
251 .with_depth_rendering(self.depth);
252
253 debug!("created an offscreen renderer of {width}x{height}");
254 Ok(renderer)
255 }
256}
257
258
259impl Default for MjRendererBuilder {
261 fn default() -> Self {
262 Self::new()
263 }
264}
265
266#[derive(Debug)]
269pub struct MjRenderer {
270 scene: MjvScene,
271 user_scene: MjvScene,
272 context: MjrContext,
273
274 gl_state: GlState,
276
277 camera: MjvCamera,
279 option: MjvOption,
280 flags: RendererFlags,
281 png_compression: png::Compression,
282 font_scale: MjtFontScale,
283
284 near: f32,
287 far: f32,
288 extra_geom: usize,
290
291 rgb: Option<Box<[u8]>>,
295 depth: Option<Box<[f32]>>,
296
297 width: usize,
298 height: usize,
299}
300
301impl MjRenderer {
302 pub fn new(model: &MjModel, width: usize, height: usize, max_user_geom: usize) -> Result<Self, RendererError> {
345 MjRendererBuilder::new()
348 .width(width.try_into().unwrap_or(u32::MAX))
349 .height(height.try_into().unwrap_or(u32::MAX))
350 .num_visual_user_geom(max_user_geom.try_into().unwrap_or(u32::MAX))
351 .build(model)
352 }
353
354 pub fn builder() -> MjRendererBuilder {
356 MjRendererBuilder::new()
357 }
358
359 pub fn scene(&self) -> &MjvScene {
361 &self.scene
362 }
363
364 pub fn user_scene(&self) -> &MjvScene {
366 &self.user_scene
367 }
368
369 pub fn user_scene_mut(&mut self) -> &mut MjvScene {
371 &mut self.user_scene
372 }
373
374 pub fn opts(&self) -> &MjvOption {
376 &self.option
377 }
378
379 pub fn opts_mut(&mut self) -> &mut MjvOption {
381 &mut self.option
382 }
383
384 pub fn camera(&self) -> &MjvCamera {
386 &self.camera
387 }
388
389 pub fn camera_mut(&mut self) -> &mut MjvCamera {
391 &mut self.camera
392 }
393
394 pub fn rgb_enabled(&self) -> bool {
396 self.flags.contains(RendererFlags::RENDER_RGB)
397 }
398
399 pub fn depth_enabled(&self) -> bool {
401 self.flags.contains(RendererFlags::RENDER_DEPTH)
402 }
403
404 pub fn set_font_scale(&mut self, font_scale: MjtFontScale) -> Result<(), RendererError> {
409 self.gl_state.make_current().map_err(RendererError::GlutinError)?;
410 self.font_scale = font_scale;
411 self.context.change_font(font_scale);
412 Ok(())
413 }
414
415 pub fn set_opts(&mut self, options: MjvOption) {
417 self.option = options;
418 }
419
420 fn update_x_from(
421 &self,
422 model: &MjModel,
423 id: usize,
424 upload: fn(&MjrContext, &MjModel, usize) -> Result<(), crate::error::MjrContextError>,
425 ) -> Result<(), RendererError>
426 {
427 self.prepare_upload(model)?;
428 upload(&self.context, model, id)?;
429 Ok(())
430 }
431
432 fn prepare_upload(&self, model: &MjModel) -> Result<(), RendererError> {
433 if !self.context.is_asset_compatible_with_model(model) {
435 return Err(RendererError::IncompatibleModel);
436 }
437 self.gl_state.make_current().map_err(RendererError::GlutinError)
438 }
439
440 fn update_all_from_impl(
441 &self,
442 model: &MjModel,
443 n: usize,
444 upload: fn(&MjrContext, &MjModel, usize) -> Result<(), crate::error::MjrContextError>,
445 ) -> Result<(), RendererError>
446 {
447 self.prepare_upload(model)?;
448 for id in 0..n {
449 upload(&self.context, model, id)?;
450 }
451 Ok(())
452 }
453
454 pub fn update_texture_from(&self, model: &MjModel, texture_id: usize) -> Result<(), RendererError> {
462 self.update_x_from(model, texture_id, MjrContext::upload_texture)
463 }
464
465 pub fn update_textures_from(&self, model: &MjModel) -> Result<(), RendererError> {
472 self.update_all_from_impl(model, model.ntex() as usize, MjrContext::upload_texture)
473 }
474
475 pub fn update_mesh_from(&self, model: &MjModel, mesh_id: usize) -> Result<(), RendererError> {
489 self.update_x_from(model, mesh_id, MjrContext::upload_mesh)
490 }
491
492 pub fn update_meshes_from(&self, model: &MjModel) -> Result<(), RendererError> {
505 self.update_all_from_impl(model, model.nmesh() as usize, MjrContext::upload_mesh)
506 }
507
508 pub fn update_hfield_from(&self, model: &MjModel, hfield_id: usize) -> Result<(), RendererError> {
516 self.update_x_from(model, hfield_id, MjrContext::upload_hfield)
517 }
518
519 pub fn update_hfields_from(&self, model: &MjModel) -> Result<(), RendererError> {
526 self.update_all_from_impl(model, model.nhfield() as usize, MjrContext::upload_hfield)
527 }
528
529 pub fn set_camera(&mut self, camera: MjvCamera) {
531 self.camera = camera;
532 }
533
534 pub fn set_rgb_rendering(&mut self, enable: bool) {
536 self.flags.set(RendererFlags::RENDER_RGB, enable);
537 self.rgb = if enable { Some(vec![0; 3 * self.width * self.height].into_boxed_slice()) } else { None } ;
538 }
539
540 pub fn set_depth_rendering(&mut self, enable: bool) {
542 self.flags.set(RendererFlags::RENDER_DEPTH, enable);
543 self.depth = if enable { Some(vec![0.0; self.width * self.height].into_boxed_slice()) } else { None } ;
544 }
545
546 pub fn with_font_scale(mut self, font_scale: MjtFontScale) -> Result<Self, RendererError> {
551 self.set_font_scale(font_scale)?;
552 Ok(self)
553 }
554
555 pub fn with_opts(mut self, options: MjvOption) -> Self {
557 self.set_opts(options);
558 self
559 }
560
561 pub fn with_camera(mut self, camera: MjvCamera) -> Self {
563 self.set_camera(camera);
564 self
565 }
566
567 pub fn with_rgb_rendering(mut self, enable: bool) -> Self {
569 self.set_rgb_rendering(enable);
570 self
571 }
572
573 pub fn with_depth_rendering(mut self, enable: bool) -> Self {
575 self.set_depth_rendering(enable);
576 self
577 }
578
579 pub fn set_png_compression(&mut self, compression: png::Compression) {
581 self.png_compression = compression;
582 }
583
584 pub fn with_png_compression(mut self, compression: png::Compression) -> Self {
586 self.set_png_compression(compression);
587 self
588 }
589
590 pub fn sync_data<M: ModelType>(&mut self, data: &mut MjData<M>) -> Result<(), RendererError> {
604 if !(
605 self.scene.is_compatible_with_model(data.model()) &&
606 self.context.is_compatible_with_model(data.model())
607 ) {
608 self.gl_state.make_current().map_err(RendererError::GlutinError)?;
613
614 let user_geom_cap = self.user_scene.maxgeom() as usize;
615 let new_ngeom = data.model().ffi().ngeom as usize;
616 self.scene = MjvScene::new(data.model(), new_ngeom + self.extra_geom);
617 self.user_scene = MjvScene::new(data.model(), user_geom_cap);
618 (self.near, self.far) = model_near_far(data.model());
619
620 self.context = unsafe { MjrContext::new(data.model()) };
624 self.context.offscreen();
625 self.context.change_font(self.font_scale);
626 }
627
628 self.scene.update(data, &self.option, &MjvPerturb::default(), &mut self.camera);
629 Ok(())
630 }
631
632 pub fn rgb_flat(&self) -> Option<&[u8]> {
634 self.rgb.as_deref()
635 }
636
637 pub fn rgb<const WIDTH: usize, const HEIGHT: usize>(&self) -> &[[[u8; 3]; WIDTH]; HEIGHT] {
646 self.try_rgb::<WIDTH, HEIGHT>().unwrap()
647 }
648
649 pub fn try_rgb<const WIDTH: usize, const HEIGHT: usize>(&self) -> Result<&[[[u8; 3]; WIDTH]; HEIGHT], RendererError> {
656 if let Some(flat) = self.rgb_flat() {
657 bytemuck::try_from_bytes(flat)
658 .map_err(|_| RendererError::DimensionMismatch)
659 }
660 else {
661 Err(RendererError::RgbDisabled)
662 }
663 }
664
665 pub fn depth_flat(&self) -> Option<&[f32]> {
669 self.depth.as_deref()
670 }
671
672 pub fn depth<const WIDTH: usize, const HEIGHT: usize>(&self) -> &[[f32; WIDTH]; HEIGHT] {
682 self.try_depth::<WIDTH, HEIGHT>().unwrap()
683 }
684
685 pub fn try_depth<const WIDTH: usize, const HEIGHT: usize>(&self) -> Result<&[[f32; WIDTH]; HEIGHT], RendererError> {
693 if let Some(flat) = self.depth_flat() {
694 let bytes: &[u8] = bytemuck::cast_slice(flat);
695 bytemuck::try_from_bytes(bytes)
696 .map_err(|_| RendererError::DimensionMismatch)
697 }
698 else {
699 Err(RendererError::DepthDisabled)
700 }
701 }
702
703 pub fn save_rgb<T: AsRef<Path>>(&self, path: T) -> Result<(), RendererError> {
710 if let Some(rgb) = &self.rgb {
711 write_png(
712 path,
713 rgb,
714 self.width as u32,
715 self.height as u32,
716 png::ColorType::Rgb,
717 png::BitDepth::Eight,
718 self.png_compression
719 )?;
720 Ok(())
721 }
722 else {
723 Err(RendererError::RgbDisabled)
724 }
725 }
726
727 pub fn save_depth<T: AsRef<Path>>(&self, path: T, normalize: bool) -> Result<(f32, f32), RendererError> {
743 if let Some(depth) = &self.depth {
744 let (norm, min, max) =
745 if normalize {
746 let max = depth.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
747 let min = depth.iter().cloned().fold(f32::INFINITY, f32::min);
748 let range = max - min;
749 if range == 0.0 {
750 (vec![0u8; depth.len() * 2].into_boxed_slice(), min, max)
751 } else {
752 (depth.iter().flat_map(|&x| (((x - min) / range * DEPTH_U16_SCALE).clamp(0.0, DEPTH_U16_SCALE) as u16).to_be_bytes()).collect::<Box<_>>(), min, max)
753 }
754 }
755 else {
756 let near = self.near;
759 let far = self.far;
760 (depth.iter().flat_map(|&x| (((x - near) / (far - near) * DEPTH_U16_SCALE).clamp(0.0, DEPTH_U16_SCALE) as u16).to_be_bytes()).collect::<Box<_>>(), near, far)
761 };
762
763 write_png(
764 path,
765 &norm,
766 self.width as u32,
767 self.height as u32,
768 png::ColorType::Grayscale,
769 png::BitDepth::Sixteen,
770 self.png_compression
771 )?;
772 Ok((min, max))
773 }
774 else {
775 Err(RendererError::DepthDisabled)
776 }
777 }
778
779 pub fn save_depth_raw<T: AsRef<Path>>(&self, path: T) -> Result<(), RendererError> {
788 if let Some(depth) = &self.depth {
789 let file = File::create(path.as_ref())?;
790 let mut writer = BufWriter::new(file);
791
792 let bytes: &[u8] = bytemuck::cast_slice(depth);
793 writer.write_all(bytes)?;
794 Ok(())
795 }
796 else {
797 Err(RendererError::DepthDisabled)
798 }
799 }
800
801 pub fn render(&mut self) -> Result<(), RendererError> {
813 sync_geoms(&self.user_scene, &mut self.scene).map_err(RendererError::SceneError)?;
815
816 self.gl_state.make_current().map_err(RendererError::GlutinError)?;
817 let vp = MjrRectangle::new(0, 0, self.width as i32, self.height as i32);
818 self.scene.render(&vp, &self.context);
819
820 let flat_rgb = self.rgb.as_deref_mut();
822 let flat_depth = self.depth.as_deref_mut();
823
824 self.context.read_pixels(
826 flat_rgb,
827 flat_depth,
828 &vp
829 ).map_err(RendererError::ContextError)?;
830
831 if let Some(rgb) = self.rgb.as_deref_mut() {
833 flip_image_vertically(rgb, self.height, self.width * 3);
834 }
835
836 if let Some(depth) = self.depth.as_deref_mut() {
838 flip_image_vertically(depth, self.height, self.width);
839
840 let near = self.near;
841 let far = self.far;
842 for value in depth {
843 *value = near / (1.0 - *value * (1.0 - near / far));
844 }
845 }
846
847 Ok(())
848 }
849}
850
851#[derive(Debug)]
853#[non_exhaustive]
854pub enum RendererError {
855 #[cfg(feature = "renderer-winit-fallback")]
857 EventLoopError(winit::error::EventLoopError),
858 GlutinError(glutin::error::Error),
860 ZeroDimension,
863 #[cfg(feature = "renderer-winit-fallback")]
865 GlInitFailed(crate::error::GlInitError),
866 RgbDisabled,
868 DepthDisabled,
870 DimensionMismatch,
872 IoError(io::Error),
874 SceneError(crate::error::MjSceneError),
876 ContextError(crate::error::MjrContextError),
878 IncompatibleModel,
881}
882
883impl Display for RendererError {
885 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
886 match self {
887 #[cfg(feature = "renderer-winit-fallback")]
888 Self::EventLoopError(_) => write!(f, "event loop failed to initialize"),
889 Self::GlutinError(_) => write!(f, "glutin operation failed"),
890 Self::ZeroDimension => write!(f, "renderer width and height must both be greater than zero"),
891 #[cfg(feature = "renderer-winit-fallback")]
892 Self::GlInitFailed(_) => write!(f, "GL initialization failed"),
893 Self::RgbDisabled => write!(f, "RGB rendering is not enabled (renderer.with_rgb_rendering(true))"),
894 Self::DepthDisabled => write!(f, "depth rendering is not enabled (renderer.with_depth_rendering(true))"),
895 Self::DimensionMismatch => write!(f, "the input width and height don't match the renderer's configuration"),
896 Self::IoError(_) => write!(f, "I/O operation failed"),
897 Self::SceneError(_) => write!(f, "scene operation failed"),
898 Self::ContextError(_) => write!(f, "rendering context operation failed"),
899 Self::IncompatibleModel => write!(f, "the model is not compatible with the scene or the rendering context: call sync_data first"),
900 }
901 }
902}
903
904impl Error for RendererError {
906 fn source(&self) -> Option<&(dyn Error + 'static)> {
907 match self {
908 #[cfg(feature = "renderer-winit-fallback")]
909 Self::EventLoopError(e) => Some(e),
910 #[cfg(feature = "renderer-winit-fallback")]
911 Self::GlInitFailed(e) => Some(e),
912 Self::GlutinError(e) => Some(e),
913 Self::IoError(e) => Some(e),
914 Self::SceneError(e) => Some(e),
915 Self::ContextError(e) => Some(e),
916 _ => None,
917 }
918 }
919}
920
921impl From<io::Error> for RendererError {
923 fn from(e: io::Error) -> Self {
924 Self::IoError(e)
925 }
926}
927
928impl From<png::EncodingError> for RendererError {
930 fn from(e: png::EncodingError) -> Self {
931 Self::IoError(io::Error::from(e))
932 }
933}
934
935impl From<crate::error::MjSceneError> for RendererError {
937 fn from(e: crate::error::MjSceneError) -> Self {
938 Self::SceneError(e)
939 }
940}
941
942impl From<crate::error::MjrContextError> for RendererError {
944 fn from(e: crate::error::MjrContextError) -> Self {
945 Self::ContextError(e)
946 }
947}
948
949#[cfg(feature = "renderer-winit-fallback")]
951impl From<crate::error::GlInitError> for RendererError {
952 fn from(e: crate::error::GlInitError) -> Self {
953 Self::GlInitFailed(e)
954 }
955}
956
957bitflags! {
958 #[derive(Debug)]
960 struct RendererFlags: u8 {
961 const RENDER_RGB = 1 << 0;
962 const RENDER_DEPTH = 1 << 1;
963 }
964}
965
966
967
968impl Drop for MjRenderer {
970 fn drop(&mut self) {
971 let _ = self.gl_state.make_current();
974 }
975}
976
977#[cfg(test)]
983mod test {
984 use crate::assert_relative_eq;
985
986 use super::*;
987
988 const MODEL: &str = stringify!(
989 <mujoco>
990
991 <visual>
992 <global offwidth="1280" offheight="720"/>
993 </visual>
994
995 <worldbody>
996 <geom name="floor" type="plane" size="10 10 1" euler="0 0 0"/>
997 <geom type="box" size="1 10 10" pos="-1 0 0" euler="0 0 0"/>
998
999 <camera name="depth_test" euler="90 90 0" pos="2.25 0 1"/>
1000
1001 </worldbody>
1002 </mujoco>
1003 );
1004
1005 #[test]
1008 #[cfg(target_os = "linux")]
1009 fn test_depth() {
1010 let model = MjModel::from_xml_string(MODEL).expect("could not load the model");
1011 let mut data = MjData::new(&model);
1012 data.step();
1013
1014 let mut renderer = MjRenderer::builder()
1015 .rgb(false)
1016 .depth(true)
1017 .camera(MjvCamera::new_fixed(model.name_to_id(MjtObjBase::mjOBJ_CAMERA, "depth_test").unwrap()))
1018 .build(&model)
1019 .unwrap();
1020
1021 renderer.sync_data(&mut data).unwrap();
1022 renderer.render().unwrap();
1023 let min = renderer.depth_flat().unwrap().iter().fold(f32::INFINITY, |a , &b| a.min(b));
1024 let max = renderer.depth_flat().unwrap().iter().fold(f32::NEG_INFINITY, |a , &b| a.max(b));
1025
1026 assert_relative_eq!(min, max, epsilon = 1e-4);
1027 assert_relative_eq!(min, 2.25, epsilon = 1e-4);
1028 }
1029
1030 #[test]
1034 #[cfg(target_os = "linux")]
1035 fn test_upload_gate() {
1036 let xml = |vertex: &str, extra: &str| format!("<mujoco><asset><mesh name='m' vertex='0 0 0 1 0 0 \
1037 0 1 0 0 0 1 {vertex} .5 .5 -.3'/>{extra}</asset><worldbody><geom type='mesh' mesh='m'/>\
1038 </worldbody></mujoco>");
1039 let base = MjModel::from_xml_string(&xml("1 1 1", "")).unwrap();
1040 let moved = MjModel::from_xml_string(&xml("1.2 1.1 1", "")).unwrap();
1041 let extra = MjModel::from_xml_string(&xml("1 1 1", "<mesh name='n' vertex='0 0 0 1 0 0 0 1 0 0 0 1'/>"))
1042 .unwrap();
1043 assert_ne!(base.mesh_graph(), moved.mesh_graph(), "the move must change the hull");
1044
1045 let renderer = MjRenderer::builder().build(&base).unwrap();
1046 renderer.update_mesh_from(&moved, 0).unwrap();
1047 renderer.update_meshes_from(&moved).unwrap();
1048 assert!(matches!(renderer.update_mesh_from(&extra, 0), Err(RendererError::IncompatibleModel)));
1049 }
1050
1051 #[test]
1053 #[cfg(target_os = "linux")]
1054 #[should_panic(expected = "ASCII")]
1055 fn test_overlay_non_ascii_panics() {
1056 let model = MjModel::from_xml_string(MODEL).unwrap();
1057 let mut renderer = MjRenderer::builder().build(&model).unwrap();
1058 renderer.context.overlay(
1059 MjtFont::mjFONT_NORMAL, MjtGridPos::mjGRID_TOPLEFT, MjrRectangle::default(), "\u{e9}", None
1060 );
1061 }
1062
1063 #[cfg(target_os = "linux")]
1065 fn decode_png_pixels(path: &std::path::Path) -> Vec<u8> {
1066 let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
1067 let mut reader = decoder.read_info().unwrap();
1068 let mut buf = vec![0u8; reader.output_buffer_size().unwrap()];
1069 reader.next_frame(&mut buf).unwrap();
1070 buf
1071 }
1072
1073 #[test]
1076 #[cfg(target_os = "linux")]
1077 fn test_png_compression_lossless() {
1078 let model = MjModel::from_xml_string(MODEL).expect("could not load the model");
1079 let mut data = MjData::new(&model);
1080 data.step();
1081
1082 let mut renderer = MjRenderer::builder()
1083 .rgb(true)
1084 .depth(false)
1085 .build(&model)
1086 .unwrap();
1087
1088 renderer.sync_data(&mut data).unwrap();
1089 renderer.render().unwrap();
1090
1091 let tmp = std::env::temp_dir();
1092 let path_none = tmp.join("mujoco_rs_test_none.png");
1093 let path_fast = tmp.join("mujoco_rs_test_fast.png");
1094 let path_high = tmp.join("mujoco_rs_test_high.png");
1095
1096 renderer.save_rgb(&path_none).unwrap();
1097 renderer.set_png_compression(png::Compression::Fast);
1098 renderer.save_rgb(&path_fast).unwrap();
1099 renderer.set_png_compression(png::Compression::High);
1100 renderer.save_rgb(&path_high).unwrap();
1101
1102 let pixels_none = decode_png_pixels(&path_none);
1103 let pixels_fast = decode_png_pixels(&path_fast);
1104 let pixels_high = decode_png_pixels(&path_high);
1105
1106 assert_eq!(pixels_none, pixels_fast, "Fast compression must produce identical pixels");
1107 assert_eq!(pixels_none, pixels_high, "High compression must produce identical pixels");
1108
1109 let _ = std::fs::remove_file(&path_none);
1110 let _ = std::fs::remove_file(&path_fast);
1111 let _ = std::fs::remove_file(&path_high);
1112 }
1113}