Skip to main content

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    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
102/// The default color for the primary representation (in 2D).
103pub const PRIMARY_COLOR: Color = Color::srgb(0.836, 0.533, 0.211);
104
105/// The default color for highlighted features.
106pub const HIGHLIGHT_COLOR: Color = Color::srgb(174.0 / 255.0, 215.0 / 255.0, 1.0);
107
108/// The default color for the primary representation (darkened for 3D lighting).
109pub const PRIMARY_COLOR_3D: Color = Color::srgb(0.7, 0.4, 0.0);
110
111/// The default color for a muted representation.
112pub const MUTED_COLOR: Color = Color::srgb(0.5, 0.5, 0.5);
113
114/// The default color for the boundary representation.
115pub const BOUNDARY_COLOR: Color = Color::srgb(0.0, 0.0, 0.0);
116
117/// Camera zoom speed multiplier
118const CAMERA_ZOOM_SPEED: f32 = 50.0;
119
120/// Interface *hoomd-rs* simulations with the Bevy game engine.
121///
122/// [`HoomdBevyPlugin`] is used by all the *hoomd-rs* examples that create
123/// interactive graphical displays of simulations. Specifically, it implements:
124///
125/// * Camera controls (2D and 3D separately).
126/// * Simulation step and frame pacing, with a limited number of steps per second.
127/// * Pause and advance by single step controls.
128/// * Screenshots.
129/// * A GUI that provides usage instructions, settings, and controls.
130///
131/// The caller must:
132/// * Add the `EguiPlugin`.
133/// * Provide type that implements [`Simulation`].
134/// * Add a `sync` `Update` system that populates (and removes) entities for
135///   rendering. See [`representation`] for helper code.
136///
137/// The caller may optionally:
138/// * Add UI to the upper left and/or right corners of the screen.
139/// * Implement custom keyboard and/or GUI controls.
140///
141/// To keep individual example scripts short and understandable, `hoomd-bevy` should
142/// implement as much common code as possible.
143///
144/// # Examples
145///
146/// See any one of the many *hoomd-rs* examples that use [`HoomdBevyPlugin`].
147///
148/// [`Simulation`]: hoomd_simulation::Simulation
149pub struct HoomdBevyPlugin<S> {
150    /// Configuration to use at application start (may be changed later).
151    pub initial_settings: Settings,
152    /// The simulation to advance and display interactively.
153    pub simulation: S,
154}
155
156/// State of the UI
157#[derive(Default, Resource)]
158pub struct UiState {
159    /// Prevent the simulation from running when true.
160    pause: bool,
161    /// Show the debug overlay.
162    show_debug: bool,
163}
164
165/// State of the options window
166///
167/// The options window is hidden by default.
168#[derive(Default, Resource)]
169struct OptionsWindowState(bool);
170
171/// State of the parameters window
172///
173/// The parameters window is shown by default.
174#[derive(Resource)]
175pub struct ParametersWindowState(pub bool);
176
177/// Reset the camera to the default.
178#[derive(Message)]
179struct ResetCamera;
180
181/// Quit the application.
182#[derive(Message)]
183struct Quit;
184
185/// Advance the simulation one step.
186#[derive(Message)]
187struct AdvanceSimulation;
188
189/// Configure the initial camera view and set how the camera will be controlled.
190#[derive(Clone)]
191pub enum InitialCamera {
192    /// Two dimensional top down camera showing the xy plane.
193    ///
194    /// The single field sets the height of the visible area. The width is set
195    /// automatically based on the window dimensions.
196    ///
197    /// Controls:
198    /// * Left click and drag to pan.
199    /// * Scroll to zoom.
200    Orthographic2d(f32),
201
202    /// Three dimensional front down camera showing the xy plane.
203    ///
204    /// The single field sets the height of the visible area. The width is set
205    /// automatically based on the window dimensions.
206    ///
207    /// Controls:
208    /// * TODO
209    Orthographic3d(f32),
210}
211
212/// Store parameters that influence how the simulation is executed.
213#[derive(Resource)]
214pub struct Settings {
215    /// Maximum fraction (0.0 to 1.0) of the frame time to use advancing the simulation.
216    pub frame_budget_fraction: f32,
217
218    /// Maximum number of steps per second to advance the simulation.
219    pub sps_limit: f32,
220
221    /// Initial camera.
222    pub camera: InitialCamera,
223
224    /// Clamp the orthographic camera's scale to this range.
225    pub zoom_range: Range<f32>,
226
227    /// Camera sensitivity.
228    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/// Total time allow to advance simulation per frame.
244#[derive(Resource)]
245struct FrameBudget(Duration);
246
247/// Settings used by the 2d camera controls.
248#[derive(Debug, Default, Resource)]
249pub struct CameraControl2d {
250    /// Coordinates clicked in the world frame.
251    world_position: Vec2,
252
253    /// Track whether the user is dragging the view.
254    dragging: bool,
255}
256
257/// The overlay UI root node.
258#[derive(Component)]
259struct OverlayRoot;
260
261/// Mark debug text.
262#[derive(Component)]
263struct DebugText;
264
265/// Mark the logo.
266#[derive(Component)]
267struct Logo;
268
269/// Systems that run to advance the simulation.
270///
271/// Callers should use this to execute the sync step after the simulation is advanced:
272/// `app.add_systems(Update, sync_simulation.run_if(resource_changed::<MySimulation>).after(AdvanceSet));`
273#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
274pub struct AdvanceSet;
275
276/// Systems that run to process non-GUI keyboard input.
277///
278/// Callers must add any keyboard input handling systems to this set.
279/// It is processed after [`AdvanceSet`] to reduce the latency between
280/// input and result and it is skipped when the GUI is capturing
281/// keyboard input.
282#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
283pub struct KeyboardInputSet;
284
285/// Systems that run to process non-GUI mouse input.
286///
287/// Callers must add any mouse input handling systems to this set.
288/// It is processed after [`AdvanceSet`] to reduce the latency between
289/// input and result and it is skipped when the GUI is capturing
290/// mouse input.
291#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
292pub struct MouseInputSet;
293
294impl<Sim> HoomdBevyPlugin<Sim>
295where
296    Sim: Resource<Mutability = Mutable> + Simulation,
297{
298    /// Bevy diagnostic that counts the number of steps executed per second.
299    pub const SPS: DiagnosticPath = DiagnosticPath::const_new("sps");
300
301    /// Clear the window to this color before rendering each frame.
302    pub const CLEAR: Color = Color::oklch(0.32, 0.0, 0.0);
303
304    /// Offset the interface from the edge of the screen.
305    pub const UI_OFFSET: f32 = 12.0;
306
307    /// Bevy system that advances the simulation forward one step.
308    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        // Determine the maximum number of steps that we can take in this update.
318        // Accumulate fractional steps over time and remove whole steps from the
319        // accumulated amount. This allows for steps per second limits that are
320        // less than the monitor's refresh rate.
321        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    /// Advance the simulation one step
349    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    /// Test if the simulation is paused in `run_if`.
367    #[must_use]
368    pub fn is_paused(state: Res<UiState>) -> bool {
369        state.pause
370    }
371
372    /// Create the full screen UI text overlay node.
373    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    /// Add debug text nodes.
392    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    /// Add the logo.
413    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    /// Remove the help reminder text.
432    fn remove_logo(mut commands: Commands, logo: Single<Entity, With<Logo>>) {
433        commands.entity(*logo).despawn();
434    }
435
436    /// Populate values in the debug text.
437    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    /// Set the time budgeted to advancing the simulation each frame.
470    ///
471    /// Derive this time from the current monitor refresh rate and the
472    /// `frame_budget_fraction` settings.
473    #[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        // adapted from: https://github.com/aevyrie/bevy_framepace/blob/main/src/lib.rs
480
481        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    /// Detect the minimum frame time for all windows.
495    #[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    /// Set up the 2D camera.
515    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    /// Set up the 3D camera.
525    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    /// Increase the brightness of the default ambient light.
543    fn setup_ambient_light(mut ambient_light: ResMut<GlobalAmbientLight>) {
544        ambient_light.brightness = 150.0;
545    }
546
547    /// Keyboard controls for the 2d camera.
548    ///
549    /// `=` resets the camera to the default.
550    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    /// Quit.
569    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    /// Left click and drag to pan the 2D camera.
578    ///
579    /// # Panics
580    ///
581    /// Panics when the 2D camera viewport is invalid.
582    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        // Firefox wasm builds do not behave well using AccumulatedMouseMotion. Use
592        // absolute window coordinates and a state machine to provide consistent
593        // panning behavior across all platforms.
594
595        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                // Pan by placing control.world_position at the cursor position
623                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    /// Zoom the 2d camera using the mouse wheel or trackpad scroll gesture.
635    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            // The scroll events distinguish between line (mouse wheel) and pixel
651            // (trackpad) events. However, In wasm builds all major browsers report
652            // only pixel events. Tested on macOS, scrolling with the trackpad gave
653            // consistent values across all browsers and native. However, scrolling
654            // with the mouse wheel gave different scales between native and browser
655            // and from browser to browser (a factor of 100 from the smallest to
656            // the largest). Therefore, the best we can do is check the sign of the
657            // scroll event and act scale the camera in the appropriate direction.
658            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    /// Build the plugin.
683    ///
684    /// [`HoomdBevyPlugin`] does not implement [`Plugin`] and cannot be used with
685    /// `add_plugins` so that the `build` method can consume `self`. This allows
686    /// `build` to take ownership of the `simulation` field and create the appropriate
687    /// Bevy [`Resource`].
688    ///
689    /// # Panics
690    ///
691    /// * When `EguiPlugin` is not added before calling `build`.
692    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    /// GUI and keyboard controls
788    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    /// GUI and keyboard controls
798    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                // Sending AppExit messages in this system causes deadlocks.
935                // Send a quit message that defers AppExit until later.
936                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(&parameters_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        // Ideally this would be called in a Startup schedule, but the egui context
983        // doesn't exist at that point.
984        Self::configure_ui(contexts)?;
985        Ok(())
986    }
987}
988
989/// Construct the default plugins.
990///
991/// This helper adds Bevy's `DefaultPlugins` by default. When the
992/// `doc-example` feature is enabled, it adds a modified set of plugins
993/// for the web.
994pub 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}