1#![doc(
5 html_favicon_url = "https://raw.githubusercontent.com/glotzerlab/hoomd-rs/7352214172a490cc716492e9724ff42720a0018a/doc/theme/favicon.svg"
6)]
7#![doc(
8 html_logo_url = "https://raw.githubusercontent.com/glotzerlab/hoomd-rs/7352214172a490cc716492e9724ff42720a0018a/doc/theme/favicon.svg"
9)]
10#![allow(
11 clippy::exhaustive_enums,
12 reason = "States are intentionally non-exhaustive."
13)]
14#![allow(
15 clippy::missing_inline_in_public_items,
16 reason = "hoomd-bevy code is not intended to be inlined."
17)]
18#![allow(
19 clippy::needless_pass_by_value,
20 reason = "Bevy requires that args are passed by value."
21)]
22#![allow(
23 clippy::cast_possible_truncation,
24 reason = "Bevy operates with f32 values."
25)]
26#![allow(clippy::too_many_arguments, reason = "Bevy requires many arguments.")]
27#![allow(clippy::too_many_lines, reason = "Bevy requires long functions.")]
28
29use std::{ops::Range, time::Duration};
64
65use anyhow::Context;
66use bevy::{
67 asset::embedded_asset,
68 ecs::component::Mutable,
69 input::{
70 common_conditions::{input_just_released, input_pressed},
71 mouse::MouseWheel,
72 },
73 platform::time::Instant,
74 prelude::*,
75 time::common_conditions::once_after_delay,
76 window::PrimaryWindow,
77};
78#[cfg(not(target_arch = "wasm32"))]
79use bevy::{
80 render::view::window::screenshot::{Screenshot, save_to_disk},
81 time::common_conditions::on_timer,
82};
83use bevy_diagnostic::{
84 Diagnostic, DiagnosticPath, Diagnostics, DiagnosticsStore, FrameTimeDiagnosticsPlugin,
85 RegisterDiagnostic,
86};
87use bevy_egui::{
88 EguiContexts, EguiPlugin, EguiPrimaryContextPass,
89 egui::{
90 self,
91 gui_zoom::kb_shortcuts::{ZOOM_IN, ZOOM_OUT, ZOOM_RESET},
92 },
93 input::{egui_wants_any_keyboard_input, egui_wants_any_pointer_input},
94};
95#[cfg(not(target_arch = "wasm32"))]
96use bevy_winit::WINIT_WINDOWS;
97
98use hoomd_simulation::Simulation;
99
100pub mod representation;
101
102pub const PRIMARY_COLOR: Color = Color::srgb(0.836, 0.533, 0.211);
104
105pub const HIGHLIGHT_COLOR: Color = Color::srgb(174.0 / 255.0, 215.0 / 255.0, 1.0);
107
108pub const PRIMARY_COLOR_3D: Color = Color::srgb(0.7, 0.4, 0.0);
110
111pub const MUTED_COLOR: Color = Color::srgb(0.5, 0.5, 0.5);
113
114pub const BOUNDARY_COLOR: Color = Color::srgb(0.0, 0.0, 0.0);
116
117const CAMERA_ZOOM_SPEED: f32 = 50.0;
119
120pub struct HoomdBevyPlugin<S> {
150 pub initial_settings: Settings,
152 pub simulation: S,
154}
155
156#[derive(Default, Resource)]
158pub struct UiState {
159 pause: bool,
161 show_debug: bool,
163}
164
165#[derive(Default, Resource)]
169struct OptionsWindowState(bool);
170
171#[derive(Resource)]
175pub struct ParametersWindowState(pub bool);
176
177#[derive(Message)]
179struct ResetCamera;
180
181#[derive(Message)]
183struct Quit;
184
185#[derive(Message)]
187struct AdvanceSimulation;
188
189#[derive(Clone)]
191pub enum InitialCamera {
192 Orthographic2d(f32),
201
202 Orthographic3d(f32),
210}
211
212#[derive(Resource)]
214pub struct Settings {
215 pub frame_budget_fraction: f32,
217
218 pub sps_limit: f32,
220
221 pub camera: InitialCamera,
223
224 pub zoom_range: Range<f32>,
226
227 pub camera_sensitivity: f32,
229}
230
231impl Default for Settings {
232 fn default() -> Self {
233 Self {
234 frame_budget_fraction: 0.8,
235 sps_limit: 2048.0,
236 camera: InitialCamera::Orthographic2d(10.0),
237 zoom_range: 0.25..10.0,
238 camera_sensitivity: 0.5,
239 }
240 }
241}
242
243#[derive(Resource)]
245struct FrameBudget(Duration);
246
247#[derive(Debug, Default, Resource)]
249pub struct CameraControl2d {
250 world_position: Vec2,
252
253 dragging: bool,
255}
256
257#[derive(Component)]
259struct OverlayRoot;
260
261#[derive(Component)]
263struct DebugText;
264
265#[derive(Component)]
267struct Logo;
268
269#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
274pub struct AdvanceSet;
275
276#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
283pub struct KeyboardInputSet;
284
285#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
292pub struct MouseInputSet;
293
294impl<Sim> HoomdBevyPlugin<Sim>
295where
296 Sim: Resource<Mutability = Mutable> + Simulation,
297{
298 pub const SPS: DiagnosticPath = DiagnosticPath::const_new("sps");
300
301 pub const CLEAR: Color = Color::oklch(0.32, 0.0, 0.0);
303
304 pub const UI_OFFSET: f32 = 12.0;
306
307 fn step_simulation(
309 mut diagnostics: Diagnostics,
310 mut exit: MessageWriter<AppExit>,
311 simulation: ResMut<Sim>,
312 time: Res<Time>,
313 mut accumulated_steps: Local<f32>,
314 settings: Res<Settings>,
315 frame_budget: ResMut<FrameBudget>,
316 ) {
317 let max_steps = settings.sps_limit * time.delta_secs();
322 *accumulated_steps += max_steps.fract();
323
324 let mut max_steps = max_steps.floor() as i64;
325 if *accumulated_steps > 1.0 {
326 max_steps += accumulated_steps.trunc() as i64;
327 *accumulated_steps = accumulated_steps.fract();
328 }
329
330 let simulation = simulation.into_inner();
331 let step_time = Instant::now();
332 let mut steps = 0;
333 while step_time.elapsed() < frame_budget.0 && steps < max_steps {
334 let result = simulation
335 .advance()
336 .with_context(|| format!("failed at step: {}", simulation.step()));
337 if let Err(error) = result {
338 error!("{error:?}");
339 exit.write(AppExit::Error(1.try_into().expect("1 is non-zero")));
340 break;
341 }
342 steps += 1;
343 }
344
345 diagnostics.add_measurement(&Self::SPS, || steps as f64 / time.delta_secs_f64());
346 }
347
348 fn advance_simulation(
350 simulation: ResMut<Sim>,
351 mut exit: MessageWriter<AppExit>,
352 mut event: MessageReader<AdvanceSimulation>,
353 ) {
354 let simulation = simulation.into_inner();
355 for _ in event.read() {
356 let result = simulation
357 .advance()
358 .with_context(|| format!("failed at step: {}", simulation.step()));
359 if let Err(error) = result {
360 error!("{error:?}");
361 exit.write(AppExit::Error(1.try_into().expect("1 is non-zero")));
362 }
363 }
364 }
365
366 #[must_use]
368 pub fn is_paused(state: Res<UiState>) -> bool {
369 state.pause
370 }
371
372 fn setup_overlay(mut commands: Commands, mut ui_scale: ResMut<UiScale>) {
374 commands.spawn((
375 Node {
376 top: Val::Px(0.0),
377 left: Val::Px(0.0),
378 width: Val::Vw(100.0),
379 height: Val::Vh(100.0),
380 align_items: AlignItems::Center,
381 justify_content: JustifyContent::Center,
382 ..default()
383 },
384 Visibility::Visible,
385 OverlayRoot,
386 ));
387
388 ui_scale.0 = 0.6;
389 }
390
391 fn setup_debug_text(mut commands: Commands, overlay_root: Single<Entity, With<OverlayRoot>>) {
393 commands.spawn((
394 Text::default(),
395 Node {
396 position_type: PositionType::Absolute,
397 bottom: Val::Px(Self::UI_OFFSET),
398 right: Val::Px(Self::UI_OFFSET),
399 ..default()
400 },
401 Visibility::Hidden,
402 DebugText,
403 children![
404 TextSpan::new("FPS:\n"),
405 TextSpan::new("SPS:\n"),
406 TextSpan::new("Step:\n"),
407 ],
408 ChildOf(*overlay_root),
409 ));
410 }
411
412 fn add_logo(mut commands: Commands, server: Res<AssetServer>) {
414 commands.spawn((
415 Node {
416 position_type: PositionType::Absolute,
417 bottom: Val::Px(Self::UI_OFFSET),
418 right: Val::Px(Self::UI_OFFSET),
419 width: Val::Px(64.0),
420 height: Val::Px(64.0),
421 ..default()
422 },
423 ImageNode {
424 image: server.load("embedded://hoomd_bevy/logo.png"),
425 ..default()
426 },
427 Logo,
428 ));
429 }
430
431 fn remove_logo(mut commands: Commands, logo: Single<Entity, With<Logo>>) {
433 commands.entity(*logo).despawn();
434 }
435
436 fn update_debug_text(
438 diagnostic: Res<DiagnosticsStore>,
439 debug_text: Single<(Entity, &Visibility), With<DebugText>>,
440 mut writer: TextUiWriter,
441 time: Res<Time>,
442 mut time_since_rerender: Local<Duration>,
443 simulation: Res<Sim>,
444 ) {
445 *time_since_rerender += time.delta();
446 let (debug_text, visibility) = *debug_text;
447
448 if visibility == Visibility::Hidden {
449 return;
450 }
451
452 if *time_since_rerender >= Duration::from_millis(100) {
453 *time_since_rerender = Duration::ZERO;
454
455 if let Some(fps) = diagnostic.get(&FrameTimeDiagnosticsPlugin::FPS)
456 && let Some(value) = fps.smoothed()
457 {
458 *writer.text(debug_text, 1) = format!(" FPS: {value:.2}\n");
459 }
460 if let Some(sps) = diagnostic.get(&Self::SPS)
461 && let Some(value) = sps.smoothed()
462 {
463 *writer.text(debug_text, 2) = format!(" SPS: {value:.2}\n");
464 }
465 *writer.text(debug_text, 3) = format!("Step: {}\n", simulation.step());
466 }
467 }
468
469 #[cfg(not(target_arch = "wasm32"))]
474 fn set_frame_budget(
475 windows: Query<Entity, With<Window>>,
476 settings: Res<Settings>,
477 mut frame_budget: ResMut<FrameBudget>,
478 ) {
479 let new_frame_budget = match Self::detect_frame_time(windows.iter()) {
482 Some(frame_time) => {
483 Duration::from_secs_f32(frame_time.as_secs_f32() * settings.frame_budget_fraction)
484 }
485 None => return,
486 };
487
488 if new_frame_budget != frame_budget.0 {
489 frame_budget.0 = new_frame_budget;
490 debug!("New simulation frame budget: {:?}", frame_budget.0);
491 }
492 }
493
494 #[cfg(not(target_arch = "wasm32"))]
496 fn detect_frame_time(windows: impl Iterator<Item = Entity>) -> Option<Duration> {
497 WINIT_WINDOWS.with_borrow(|winit| {
498 let best_framerate = {
499 f64::from(
500 windows
501 .filter_map(|e| winit.get_window(e))
502 .filter_map(|w| w.current_monitor())
503 .filter_map(|monitor| monitor.refresh_rate_millihertz())
504 .min()?,
505 ) / 1000.0
506 - 0.5
507 };
508
509 let best_frame_time = Duration::from_secs_f64(1.0 / best_framerate);
510 Some(best_frame_time)
511 })
512 }
513
514 fn setup_camera_2d(mut commands: Commands, viewport_height: f32) {
516 let projection = Projection::Orthographic(OrthographicProjection {
517 scaling_mode: bevy::camera::ScalingMode::FixedVertical { viewport_height },
518 ..OrthographicProjection::default_2d()
519 });
520
521 commands.spawn((Camera2d, projection));
522 }
523
524 fn setup_camera_3d(mut commands: Commands, viewport_height: f32) {
526 let projection = Projection::Orthographic(OrthographicProjection {
527 scaling_mode: bevy::camera::ScalingMode::FixedVertical { viewport_height },
528 ..OrthographicProjection::default_3d()
529 });
530
531 commands.spawn((
532 Camera3d::default(),
533 projection,
534 Transform::from_xyz(0.0, 0.0, -viewport_height * 2.0).looking_at(Vec3::ZERO, Vec3::Y),
535 ));
536 commands.spawn((
537 DirectionalLight::default(),
538 Transform::from_xyz(-3.0, 3.0, -6.0).looking_at(Vec3::ZERO, Vec3::Y),
539 ));
540 }
541
542 fn setup_ambient_light(mut ambient_light: ResMut<GlobalAmbientLight>) {
544 ambient_light.brightness = 150.0;
545 }
546
547 fn camera_reset_2d(
551 mut reset_camera: MessageReader<ResetCamera>,
552 camera: Single<(&mut Transform, &mut Projection), With<Camera2d>>,
553 mut control: ResMut<CameraControl2d>,
554 ) {
555 let (mut transform, projection) = camera.into_inner();
556
557 if !reset_camera.is_empty() {
558 if let Projection::Orthographic(ref mut orthographic) = *projection.into_inner() {
559 orthographic.scale = 1.0;
560 }
561 control.dragging = false;
562 transform.translation = Vec3::default();
563 }
564
565 reset_camera.clear();
566 }
567
568 fn quit(mut quit: MessageReader<Quit>, mut exit: MessageWriter<AppExit>) {
570 if !quit.is_empty() {
571 exit.write(AppExit::Success);
572 }
573
574 quit.clear();
575 }
576
577 fn camera_mouse_pan_control_2d(
583 camera: Single<
584 (&Camera, &GlobalTransform, &mut Transform, &mut Projection),
585 With<Camera2d>,
586 >,
587 mut control: ResMut<CameraControl2d>,
588 buttons: Res<ButtonInput<MouseButton>>,
589 window: Single<&Window, With<PrimaryWindow>>,
590 ) {
591 let (camera, global_transform, mut transform, projection) = camera.into_inner();
596
597 let viewport_size = camera
598 .logical_viewport_size()
599 .unwrap_or(Vec2::new(1280.0, 720.0));
600
601 if let Projection::Orthographic(ref mut orthographic) = *projection.into_inner() {
602 if buttons.just_pressed(MouseButton::Left)
603 && let Some(world_position) = window
604 .cursor_position()
605 .and_then(|cursor| camera.viewport_to_world_2d(global_transform, cursor).ok())
606 {
607 control.world_position = world_position;
608 control.dragging = true;
609 return;
610 }
611
612 if !buttons.pressed(MouseButton::Left) {
613 control.dragging = false;
614 return;
615 }
616
617 if control.dragging
618 && let Some(current_cursor_position) = window.cursor_position()
619 {
620 let pixel_scale = orthographic.area.size() / viewport_size;
621
622 let desired_cursor_position = camera
624 .world_to_viewport(global_transform, Vec3::from((control.world_position, 0.0)))
625 .expect("viewport should be valid");
626
627 let offset = (desired_cursor_position - current_cursor_position) * pixel_scale;
628 transform.translation.x += offset.x;
629 transform.translation.y -= offset.y;
630 }
631 }
632 }
633
634 fn camera_mouse_zoom_control_2d(
636 time: Res<Time>,
637 camera: Single<
638 (&Camera, &GlobalTransform, &mut Transform, &mut Projection),
639 With<Camera2d>,
640 >,
641 settings: Res<Settings>,
642 mut scroll: MessageReader<MouseWheel>,
643 window: Single<&Window, With<PrimaryWindow>>,
644 ) {
645 let (camera, global_transform, mut transform, projection) = camera.into_inner();
646
647 if let Projection::Orthographic(ref mut orthographic) = *projection.into_inner() {
648 let scroll = scroll.read().map(|e| e.y).fold(0.0, |total, y| total + y);
649
650 let zoom_speed = settings.camera_sensitivity * CAMERA_ZOOM_SPEED * time.delta_secs();
659 let delta_zoom = -zoom_speed.copysign(scroll);
660 let new_scale = (orthographic.scale * (1.0 + delta_zoom)).clamp(
661 1.0 / settings.zoom_range.end,
662 1.0 / settings.zoom_range.start,
663 );
664 let scale_ratio = new_scale / orthographic.scale;
665
666 let world_position_result = window
667 .cursor_position()
668 .and_then(|cursor| camera.viewport_to_world_2d(global_transform, cursor).ok());
669
670 let delta_translation = match world_position_result {
671 None => Vec2::default(),
672 Some(world_position) => {
673 (world_position - transform.translation.xy()) * (1.0 - scale_ratio)
674 }
675 };
676
677 orthographic.scale = new_scale;
678 transform.translation += Vec3::from((delta_translation, 0.0));
679 }
680 }
681
682 pub fn build(self, app: &mut App) {
693 representation::disk::build(app);
694 representation::ellipse::build(app);
695 representation::hyperbolic_disk::build(app);
696 representation::hyperbolic_polygon::build(app);
697
698 embedded_asset!(app, "logo.png");
699
700 let initial_camera = self.initial_settings.camera.clone();
701
702 assert!(app.is_plugin_added::<EguiPlugin>());
703
704 app.add_plugins(FrameTimeDiagnosticsPlugin::default())
705 .insert_resource(ClearColor(Self::CLEAR))
706 .insert_resource(FrameBudget(Duration::from_millis(9)))
707 .insert_resource(self.initial_settings)
708 .register_diagnostic(Diagnostic::new(Self::SPS))
709 .insert_resource(self.simulation)
710 .insert_resource(UiState::default())
711 .insert_resource(OptionsWindowState::default())
712 .insert_resource(ParametersWindowState(true))
713 .add_systems(
714 Startup,
715 (Self::setup_overlay, Self::setup_debug_text, Self::add_logo).chain(),
716 )
717 .add_systems(
718 Update,
719 Self::remove_logo.run_if(once_after_delay(Duration::from_secs(3))),
720 )
721 .add_systems(Update, Self::step_simulation.in_set(AdvanceSet))
722 .add_systems(
723 Update,
724 Self::advance_simulation.run_if(on_message::<AdvanceSimulation>),
725 )
726 .add_systems(Update, Self::update_debug_text.after(AdvanceSet))
727 .add_systems(EguiPrimaryContextPass, Self::ui_system)
728 .add_message::<ResetCamera>()
729 .add_message::<AdvanceSimulation>()
730 .add_message::<Quit>()
731 .add_systems(Update, Self::quit.run_if(on_message::<Quit>));
732
733 match initial_camera {
734 InitialCamera::Orthographic2d(initial_viewport_height) => {
735 app.add_systems(
736 Update,
737 Self::camera_mouse_pan_control_2d
738 .run_if(
739 input_pressed(MouseButton::Left)
740 .or_else(input_just_released(MouseButton::Left)),
741 )
742 .in_set(MouseInputSet),
743 )
744 .add_systems(
745 Update,
746 Self::camera_mouse_zoom_control_2d
747 .run_if(on_message::<MouseWheel>)
748 .in_set(MouseInputSet),
749 )
750 .add_systems(
751 Update,
752 Self::camera_reset_2d.run_if(on_message::<ResetCamera>),
753 )
754 .insert_resource(CameraControl2d::default())
755 .add_systems(Startup, move |commands: Commands| {
756 Self::setup_camera_2d(commands, initial_viewport_height);
757 });
758 }
759 InitialCamera::Orthographic3d(initial_viewport_height) => {
760 app.add_systems(Startup, move |commands: Commands| {
761 Self::setup_camera_3d(commands, initial_viewport_height);
762 })
763 .add_systems(Startup, Self::setup_ambient_light);
764 }
765 }
766
767 #[cfg(not(target_arch = "wasm32"))]
768 app.add_systems(
769 Update,
770 Self::set_frame_budget.run_if(on_timer(Duration::from_millis(250))),
771 );
772
773 app.configure_sets(
774 Update,
775 (
776 AdvanceSet.run_if(not(Self::is_paused)),
777 KeyboardInputSet
778 .after(AdvanceSet)
779 .run_if(not(egui_wants_any_keyboard_input)),
780 MouseInputSet
781 .after(AdvanceSet)
782 .run_if(not(egui_wants_any_pointer_input)),
783 ),
784 );
785 }
786
787 fn configure_ui(mut contexts: EguiContexts) -> Result {
789 let context = contexts.ctx_mut()?;
790 context.memory_mut(|m| {
791 m.options.theme_preference = egui::ThemePreference::Dark;
792 });
793
794 Ok(())
795 }
796
797 fn ui_system(
799 #[cfg(not(target_arch = "wasm32"))] mut commands: Commands,
800 mut contexts: EguiContexts,
801 mut ui_state: ResMut<UiState>,
802 mut options_window_state: ResMut<OptionsWindowState>,
803 mut parameters_window_state: ResMut<ParametersWindowState>,
804 mut settings: ResMut<Settings>,
805 window: Single<&Window, With<PrimaryWindow>>,
806 mut debug_text: Single<&mut Visibility, (With<DebugText>, Without<OverlayRoot>)>,
807 #[cfg(not(target_arch = "wasm32"))] mut quit: MessageWriter<Quit>,
808 mut reset_camera: MessageWriter<ResetCamera>,
809 mut advance_simulation: MessageWriter<AdvanceSimulation>,
810 ) -> Result {
811 let advance_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::N);
812 let options_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::M);
813 let parameters_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::P);
814 let pause_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::Space);
815 #[cfg(not(target_arch = "wasm32"))]
816 let quit_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::Q);
817 let reset_camera_shortcut =
818 egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::Equals);
819 let show_debug_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::F5);
820 #[cfg(not(target_arch = "wasm32"))]
821 let screenshot_shortcut =
822 egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::F12);
823
824 let default_width = 280.0;
825
826 let window = egui::Window::new("⛭ Options")
827 .open(&mut options_window_state.0)
828 .resizable([true, false])
829 .pivot(egui::Align2::LEFT_BOTTOM)
830 .default_pos([
831 Self::UI_OFFSET,
832 window.resolution.height() - Self::UI_OFFSET,
833 ])
834 .collapsible(false)
835 .default_width(default_width);
836
837 window.show(contexts.ctx_mut()?, |ui| {
838 ui.allocate_space(ui.available_width() * egui::vec2(1.0, 0.0));
839
840 egui::CollapsingHeader::new("Simulation controls")
841 .default_open(true)
842 .show(ui, |ui| {
843 ui.horizontal(|ui| {
844 ui.toggle_value(&mut ui_state.pause, "⏸ Pause (space)");
845 if ui.button("▶ Advance (n)").clicked() {
846 advance_simulation.write(AdvanceSimulation);
847 }
848 });
849 ui.add(
850 egui::Slider::new(&mut settings.sps_limit, 0.25..=32_768.0)
851 .text("Limit step rate")
852 .update_while_editing(false)
853 .logarithmic(true)
854 .suffix(" Hz"),
855 );
856 });
857
858 ui.collapsing("Camera controls", |ui| {
859 match settings.camera {
860 InitialCamera::Orthographic2d(_) => {
861 ui.label("Click and drag to move the camera.");
862 ui.label("Scroll to zoom.");
863 }
864 InitialCamera::Orthographic3d(_) => {
865 ui.label("TODO.");
866 ui.label("TODO.");
867 }
868 }
869
870 ui.add(
871 egui::Slider::new(&mut settings.camera_sensitivity, 0.1..=1.0)
872 .text("Camera sensitivity")
873 .update_while_editing(false),
874 );
875
876 ui.add(
877 egui::Slider::new(&mut settings.zoom_range.end, 2.0..=100.0)
878 .text("Maximum zoom")
879 .update_while_editing(false),
880 );
881
882 ui.horizontal(|ui| {
883 if ui.button("↺ Reset (=)").clicked() {
884 reset_camera.write(ResetCamera);
885 }
886
887 #[cfg(not(target_arch = "wasm32"))]
888 if ui
889 .button("📷 Screenshot (F12)")
890 .on_hover_text("Write screenshot.png to the current working directory")
891 .clicked()
892 {
893 commands
894 .spawn(Screenshot::primary_window())
895 .observe(save_to_disk("screenshot.png"));
896 }
897 });
898 });
899
900 ui.collapsing("More keyboard shortcuts", |ui| {
901 egui::Grid::new("some_unique_id").show(ui, |ui| {
902 ui.label("m");
903 ui.label("Show/hide options");
904 ui.end_row();
905
906 ui.label(ui.ctx().format_shortcut(&ZOOM_IN));
907 ui.label("Zoom UI in");
908 ui.end_row();
909
910 ui.label(ui.ctx().format_shortcut(&ZOOM_OUT));
911 ui.label("Zoom UI out");
912 ui.end_row();
913
914 ui.label(ui.ctx().format_shortcut(&ZOOM_RESET));
915 ui.label("Reset UI zoom");
916 ui.end_row();
917 });
918 });
919
920 ui.collapsing("Advanced settings", |ui| {
921 ui.checkbox(&mut parameters_window_state.0, "Show parameters (p)");
922 ui.checkbox(&mut ui_state.show_debug, "Show debug overlay (F5)");
923
924 ui.add(
925 egui::Slider::new(&mut settings.frame_budget_fraction, 0.1..=0.9)
926 .text("Simulation fraction")
927 .update_while_editing(false),
928 )
929 .on_hover_text("Decrease this when FPS is limited by rendering");
930 });
931
932 #[cfg(not(target_arch = "wasm32"))]
933 if ui.button("⊗ Quit (q)").clicked() {
934 quit.write(Quit);
937 }
938 });
939
940 {
941 let context = contexts.ctx_mut()?;
942 if !context.egui_wants_keyboard_input() {
943 if context.input_mut(|i| i.consume_shortcut(&advance_shortcut)) {
944 advance_simulation.write(AdvanceSimulation);
945 }
946 if context.input_mut(|i| i.consume_shortcut(&options_shortcut)) {
947 options_window_state.0 = !options_window_state.0;
948 }
949 if context.input_mut(|i| i.consume_shortcut(¶meters_shortcut)) {
950 parameters_window_state.0 = !parameters_window_state.0;
951 }
952 if context.input_mut(|i| i.consume_shortcut(&pause_shortcut)) {
953 ui_state.pause = !ui_state.pause;
954 }
955 if context.input_mut(|i| i.consume_shortcut(&show_debug_shortcut)) {
956 ui_state.show_debug = !ui_state.show_debug;
957 }
958 if context.input_mut(|i| i.consume_shortcut(&reset_camera_shortcut)) {
959 reset_camera.write(ResetCamera);
960 }
961
962 #[cfg(not(target_arch = "wasm32"))]
963 if context.input_mut(|i| i.consume_shortcut(&quit_shortcut)) {
964 quit.write(Quit);
965 }
966 #[cfg(not(target_arch = "wasm32"))]
967 if context.input_mut(|i| i.consume_shortcut(&screenshot_shortcut)) {
968 commands
969 .spawn(Screenshot::primary_window())
970 .observe(save_to_disk("screenshot.png"));
971 }
972 }
973 }
974
975 if **debug_text == Visibility::Hidden && ui_state.show_debug {
976 debug_text.toggle_inherited_hidden();
977 }
978 if **debug_text != Visibility::Hidden && !ui_state.show_debug {
979 debug_text.toggle_inherited_hidden();
980 }
981
982 Self::configure_ui(contexts)?;
985 Ok(())
986 }
987}
988
989pub fn add_default_plugins(app: &mut App) {
995 if cfg!(feature = "doc-example") {
996 app.add_plugins(DefaultPlugins.set(WindowPlugin {
997 primary_window: Some(Window {
998 canvas: Some("#hoomd-example".into()),
999 fit_canvas_to_parent: true,
1000 focused: false,
1001 ..default()
1002 }),
1003 ..default()
1004 }));
1005 } else {
1006 app.add_plugins(DefaultPlugins);
1007 }
1008}