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