hoomd_bevy/
lib.rs

1// Copyright (c) 2024-2026 The Regents of the University of Michigan.
2// Part of hoomd-rs, released under the BSD 3-Clause License.
3
4#![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
29//! Connect *hoomd-rs* simulations with the Bevy game engine.
30//!
31//! Use [`HoomdBevyPlugin`] to create visual, interactive simulations. Add the
32//! plugin to a Bevy `App` and it will step the simulation up to a configurable
33//! limit number of steps per second. To display geometry on the screen, add one
34//! more more `setup` methods from [`representation`] to the `Startup` schedule.
35//! Then add a `sync` method to the `Update` schedule that synchronizes the entire
36//! microstate (using the helper methods from [`representation`]).
37//!
38//! # Examples
39//!
40//! Many of the examples use [`HoomdBevyPlugin`]. Find them in the [`examples`]
41//! directory in the *hoomd-rs* repository.
42//!
43//! [`examples`]: https://github.com/glotzerlab/hoomd-rs/tree/trunk/examples
44//!
45//! # Embedded assets.
46//!
47//! `hoomd-bevy` provides the following assets:
48//!
49//! `embedded://hoomd_bevy/logo.png` - The HOOMD logo (512 x 512).
50//!
51//! # Feature flags
52//!
53//! `doc-example` Make examples suitable for display in a web browser.
54//! `webgpu` Compile for the WebGPU platform when building for the wasm32 target.
55//!
56//! # Complete documentation
57//!
58//! `hoomd-bevy` is is a part of *hoomd-rs*. Read the [complete documentation]
59//! for more information.
60//!
61//! [complete documentation]: https://hoomd-rs.readthedocs.io
62
63use 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
101/// The default color for the primary representation (in 2D).
102pub const PRIMARY_COLOR: Color = Color::srgb(249.0 / 255.0, 203.0 / 255.0, 136.0 / 255.0);
103
104/// The default color for highlighted features.
105pub const HIGHLIGHT_COLOR: Color = Color::srgb(174.0 / 255.0, 215.0 / 255.0, 1.0);
106
107/// The default color for the primary representation (darkened for 3D lighting).
108pub const PRIMARY_COLOR_3D: Color = Color::srgb(0.836, 0.533, 0.211);
109
110/// The default color for a muted representation.
111pub const MUTED_COLOR: Color = Color::srgb(0.75, 0.75, 0.75);
112
113/// The default color for the boundary representation.
114pub const BOUNDARY_COLOR: Color = Color::srgb(0.0, 0.0, 0.0);
115
116/// Camera zoom speed multiplier
117const CAMERA_ZOOM_SPEED: f32 = 50.0;
118
119/// Interface *hoomd-rs* simulations with the Bevy game engine.
120///
121/// [`HoomdBevyPlugin`] is used by all the *hoomd-rs* examples that create
122/// interactive graphical displays of simulations. Specifically, it implements:
123///
124/// * Camera controls (2D and 3D separately).
125/// * Simulation step and frame pacing, with a limited number of steps per second.
126/// * Pause and advance by single step controls.
127/// * Screenshots.
128/// * A GUI that provides usage instructions, settings, and controls.
129///
130/// The caller must:
131/// * Add the `EguiPlugin`.
132/// * Provide type that implements [`Simulation`].
133/// * Add a `sync` `Update` system that populates (and removes) entities for
134///   rendering. See [`representation`] for helper code.
135///
136/// The caller may optionally:
137/// * Add UI to the upper left and/or right corners of the screen.
138/// * Implement custom keyboard and/or GUI controls.
139///
140/// To keep individual example scripts short and understandable, `hoomd-bevy` should
141/// implement as much common code as possible.
142///
143/// # Examples
144///
145/// See any one of the many *hoomd-rs* examples that use [`HoomdBevyPlugin`].
146///
147/// [`Simulation`]: hoomd_simulation::Simulation
148pub struct HoomdBevyPlugin<S> {
149    /// Configuration to use at application start (may be changed later).
150    pub initial_settings: Settings,
151    /// The simulation to advance and display interactively.
152    pub simulation: S,
153}
154
155/// State of the UI
156#[derive(Default, Resource)]
157pub struct UiState {
158    /// Prevent the simulation from running when true.
159    pause: bool,
160    /// Show the debug overlay.
161    show_debug: bool,
162}
163
164/// State of the options window
165///
166/// The options window is hidden by default.
167#[derive(Default, Resource)]
168struct OptionsWindowState(bool);
169
170/// State of the parameters window
171///
172/// The parameters window is shown by default.
173#[derive(Resource)]
174pub struct ParametersWindowState(pub bool);
175
176/// Reset the camera to the default.
177#[derive(Message)]
178struct ResetCamera;
179
180/// Quit the application.
181#[derive(Message)]
182struct Quit;
183
184/// Advance the simulation one step.
185#[derive(Message)]
186struct AdvanceSimulation;
187
188/// Configure the initial camera view and set how the camera will be controlled.
189#[derive(Clone)]
190pub enum InitialCamera {
191    /// Two dimensional top down camera showing the xy plane.
192    ///
193    /// The single field sets the height of the visible area. The width is set
194    /// automatically based on the window dimensions.
195    ///
196    /// Controls:
197    /// * Left click and drag to pan.
198    /// * Scroll to zoom.
199    Orthographic2d(f32),
200
201    /// Three dimensional front down camera showing the xy plane.
202    ///
203    /// The single field sets the height of the visible area. The width is set
204    /// automatically based on the window dimensions.
205    ///
206    /// Controls:
207    /// * TODO
208    Orthographic3d(f32),
209}
210
211/// Store parameters that influence how the simulation is executed.
212#[derive(Resource)]
213pub struct Settings {
214    /// Maximum fraction (0.0 to 1.0) of the frame time to use advancing the simulation.
215    pub frame_budget_fraction: f32,
216
217    /// Maximum number of steps per second to advance the simulation.
218    pub sps_limit: f32,
219
220    /// Initial camera.
221    pub camera: InitialCamera,
222
223    /// Clamp the orthographic camera's scale to this range.
224    pub zoom_range: Range<f32>,
225
226    /// Camera sensitivity.
227    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/// Total time allow to advance simulation per frame.
243#[derive(Resource)]
244struct FrameBudget(Duration);
245
246/// Settings used by the 2d camera controls.
247#[derive(Debug, Default, Resource)]
248pub struct CameraControl2d {
249    /// Coordinates clicked in the world frame.
250    world_position: Vec2,
251
252    /// Track whether the user is dragging the view.
253    dragging: bool,
254}
255
256/// The overlay UI root node.
257#[derive(Component)]
258struct OverlayRoot;
259
260/// Mark debug text.
261#[derive(Component)]
262struct DebugText;
263
264/// Mark the logo.
265#[derive(Component)]
266struct Logo;
267
268/// Systems that run to advance the simulation.
269///
270/// Callers should use this to execute the sync step after the simulation is advanced:
271/// `app.add_systems(Update, sync_simulation.run_if(resource_changed::<MySimulation>).after(AdvanceSet));`
272#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
273pub struct AdvanceSet;
274
275/// Systems that run to process non-GUI keyboard input.
276///
277/// Callers must add any keyboard input handling systems to this set.
278/// It is processed after [`AdvanceSet`] to reduce the latency between
279/// input and result and it is skipped when the GUI is capturing
280/// keyboard input.
281#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
282pub struct KeyboardInputSet;
283
284/// Systems that run to process non-GUI mouse input.
285///
286/// Callers must add any mouse input handling systems to this set.
287/// It is processed after [`AdvanceSet`] to reduce the latency between
288/// input and result and it is skipped when the GUI is capturing
289/// mouse input.
290#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
291pub struct MouseInputSet;
292
293impl<Sim> HoomdBevyPlugin<Sim>
294where
295    Sim: Resource + Simulation,
296{
297    /// Bevy diagnostic that counts the number of steps executed per second.
298    pub const SPS: DiagnosticPath = DiagnosticPath::const_new("sps");
299
300    /// Clear the window to this color before rendering each frame.
301    pub const CLEAR: Color = Color::oklch(0.32, 0.0, 0.0);
302
303    /// Offset the interface from the edge of the screen.
304    pub const UI_OFFSET: f32 = 12.0;
305
306    /// Bevy system that advances the simulation forward one step.
307    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        // Determine the maximum number of steps that we can take in this update.
317        // Accumulate fractional steps over time and remove whole steps from the
318        // accumulated amount. This allows for steps per second limits that are
319        // less than the monitor's refresh rate.
320        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    /// Advance the simulation one step
348    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    /// Test if the simulation is paused in `run_if`.
366    #[must_use]
367    pub fn is_paused(state: Res<UiState>) -> bool {
368        state.pause
369    }
370
371    /// Create the full screen UI text overlay node.
372    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    /// Add debug text nodes.
391    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    /// Add the logo.
412    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    /// Remove the help reminder text.
431    fn remove_logo(mut commands: Commands, logo: Single<Entity, With<Logo>>) {
432        commands.entity(*logo).despawn();
433    }
434
435    /// Populate values in the debug text.
436    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    /// Set the time budgeted to advancing the simulation each frame.
469    ///
470    /// Derive this time from the current monitor refresh rate and the
471    /// `frame_budget_fraction` settings.
472    #[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        // adapted from: https://github.com/aevyrie/bevy_framepace/blob/main/src/lib.rs
479
480        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    /// Detect the minimum frame time for all windows.
494    #[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    /// Set up the 2D camera.
514    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    /// Set up the 3D camera.
524    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    /// Increase the brightness of the default ambient light.
542    fn setup_ambient_light(mut ambient_light: ResMut<GlobalAmbientLight>) {
543        ambient_light.brightness = 150.0;
544    }
545
546    /// Keyboard controls for the 2d camera.
547    ///
548    /// `=` resets the camera to the default.
549    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    /// Quit.
568    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    /// Left click and drag to pan the 2D camera.
577    ///
578    /// # Panics
579    ///
580    /// Panics when the 2D camera viewport is invalid.
581    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        // Firefox wasm builds do not behave well using AccumulatedMouseMotion. Use
591        // absolute window coordinates and a state machine to provide consistent
592        // panning behavior across all platforms.
593
594        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                // Pan by placing control.world_position at the cursor position
622                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    /// Zoom the 2d camera using the mouse wheel or trackpad scroll gesture.
634    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            // The scroll events distinguish between line (mouse wheel) and pixel
650            // (trackpad) events. However, In wasm builds all major browsers report
651            // only pixel events. Tested on macOS, scrolling with the trackpad gave
652            // consistent values across all browsers and native. However, scrolling
653            // with the mouse wheel gave different scales between native and browser
654            // and from browser to browser (a factor of 100 from the smallest to
655            // the largest). Therefore, the best we can do is check the sign of the
656            // scroll event and act scale the camera in the appropriate direction.
657            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    /// Build the plugin.
682    ///
683    /// [`HoomdBevyPlugin`] does not implement [`Plugin`] and cannot be used with
684    /// `add_plugins` so that the `build` method can consume `self`. This allows
685    /// `build` to take ownership of the `simulation` field and create the appropriate
686    /// Bevy [`Resource`].
687    ///
688    /// # Panics
689    ///
690    /// * When `EguiPlugin` is not added before calling `build`.
691    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    /// GUI and keyboard controls
787    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            // bevy_egui overrides the egui built-in zoom. Disable it to avoid conflicts.
793            m.options.zoom_with_keyboard = false;
794        });
795
796        Ok(())
797    }
798
799    /// GUI and keyboard controls
800    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                // Sending AppExit messages in this system causes deadlocks.
938                // Send a quit message that defers AppExit until later.
939                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(&parameters_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        // Ideally this would be called in a Startup schedule, but the egui context
999        // doesn't exist at that point.
1000        Self::configure_ui(contexts)?;
1001        Ok(())
1002    }
1003}
1004
1005/// Construct the default plugins.
1006///
1007/// This helper adds Bevy's `DefaultPlugins` by default. When the
1008/// `doc-example` feature is enabled, it adds a modified set of plugins
1009/// for the web.
1010pub 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}