Skip to main content

pan_orbit_camera_cad/
pan_orbit_camera_cad.rs

1//! Showcases how a pan-orbit-zoom camera can be used to inspect a CAD model.
2//!
3//! Features:
4//! - Smoothly between perspective and orthographic projection (P)
5//! - Toggle between free and constrained orbit (should the camera always be "facing up"?) (C)
6//! - Snap to 6 axis-aligned views (1-6)
7//! - Explode the model, separating its parts for inspection (E)
8//! - Toggling passing thought surface on minimal zool (Z)
9
10mod pan_orbit_camera_custom_input_plugin;
11
12use std::time::Duration;
13
14use bevy::camera::Hdr;
15use bevy::camera_controller::pan_orbit_camera::{
16    extensions::{dolly_zoom::DollyZoomTrigger, look_to::LookToTrigger},
17    prelude::*,
18};
19use bevy::math::DVec3;
20use bevy::{
21    anti_alias::smaa::Smaa, camera::primitives::Aabb, core_pipeline::tonemapping::Tonemapping,
22    pbr::ScreenSpaceAmbientOcclusion, platform::time::Instant, post_process::bloom::Bloom,
23    prelude::*, window::RequestRedraw,
24};
25
26use crate::pan_orbit_camera_custom_input_plugin::CustomInputPlugin;
27fn main() {
28    App::new()
29        .add_plugins((
30            DefaultPlugins,
31            MeshPickingPlugin,
32            DefaultPanOrbitCameraPlugins,
33            // Currently PanOrbitCamera needs an external plugin to handle input mapping.
34            CustomInputPlugin,
35        ))
36        // The camera controller works with reactive rendering:
37        // .insert_resource(bevy::winit::WinitSettings::desktop_app())
38        .insert_resource(GlobalAmbientLight::NONE)
39        .add_systems(Startup, setup)
40        .add_systems(
41            Update,
42            (
43                toggle_projection,
44                toggle_constraint,
45                explode,
46                switch_direction,
47                toggle_zoom,
48            )
49                .chain(),
50        )
51        .run();
52}
53
54fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
55    // Original envmaps:
56    // let diffuse_map = asset_server.load(get_web_asset_url("bevy_editor_cam_environment_maps/diffuse_rgb9e5_zstd.ktx2"));
57    // let specular_map = asset_server.load(get_web_asset_url("bevy_editor_cam_environment_maps/specular_rgb9e5_zstd.ktx2"));
58    // let intensity = 1000.0;
59
60    let diffuse_map = asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2");
61    let specular_map = asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2");
62    let intensity = 5000.0;
63
64    let scene = asset_server.load(get_web_asset_url("PlaneEngine/scene.gltf#Scene0"));
65
66    commands.spawn((
67        WorldAssetRoot(scene),
68        Transform::from_scale(Vec3::splat(2.0)),
69    ));
70
71    let cam_trans = Transform::from_xyz(2.0, 2.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y);
72    let camera = commands
73        .spawn((
74            Camera3d::default(),
75            Hdr,
76            Camera::default(),
77            cam_trans,
78            Tonemapping::AcesFitted,
79            Bloom::default(),
80            EnvironmentMapLight {
81                intensity,
82                diffuse_map: diffuse_map.clone(),
83                specular_map: specular_map.clone(),
84                ..Default::default()
85            },
86            PanOrbitCamera {
87                orbit_constraint: OrbitConstraint::Free,
88                last_anchor_depth: -cam_trans.translation.length() as f64,
89                orthographic: projections::OrthographicSettings {
90                    scale_to_near_clip: 1_000_f32, // Needed for SSAO to work in ortho
91                    ..Default::default()
92                },
93                ..Default::default()
94            },
95            ScreenSpaceAmbientOcclusion::default(),
96            Smaa::default(),
97            Msaa::Off,
98        ))
99        .id();
100
101    setup_ui(commands, camera);
102}
103
104fn toggle_projection(
105    keys: Res<ButtonInput<KeyCode>>,
106    mut dolly: MessageWriter<DollyZoomTrigger>,
107    cam: Query<Entity, With<PanOrbitCamera>>,
108    mut toggled: Local<bool>,
109) {
110    if keys.just_pressed(KeyCode::KeyP) {
111        *toggled = !*toggled;
112        let target_projection = if *toggled {
113            Projection::Orthographic(OrthographicProjection::default_3d())
114        } else {
115            Projection::Perspective(PerspectiveProjection::default())
116        };
117        dolly.write(DollyZoomTrigger {
118            target_projection,
119            camera: cam.single().unwrap(),
120        });
121    }
122}
123
124#[derive(Component)]
125struct ZoomStatusIndicator;
126
127fn zoom_status(zoom_through: bool) -> String {
128    format!("Zoom Through: {zoom_through}")
129}
130
131fn toggle_zoom(
132    keys: Res<ButtonInput<KeyCode>>,
133    mut cam: Query<&mut PanOrbitCamera>,
134    mut text: Query<&mut Text, With<ZoomStatusIndicator>>,
135) {
136    if keys.just_pressed(KeyCode::KeyZ) {
137        let mut editor = cam.single_mut().unwrap();
138        editor.zoom_limits.zoom_through_objects = !editor.zoom_limits.zoom_through_objects;
139        text.single_mut().unwrap().0 = zoom_status(editor.zoom_limits.zoom_through_objects);
140    }
141}
142
143fn toggle_constraint(
144    keys: Res<ButtonInput<KeyCode>>,
145    mut cam: Query<(Entity, &Transform, &mut PanOrbitCamera)>,
146    mut look_to: MessageWriter<LookToTrigger>,
147) {
148    if keys.just_pressed(KeyCode::KeyC) {
149        let (entity, transform, mut editor) = cam.single_mut().unwrap();
150        match editor.orbit_constraint {
151            OrbitConstraint::Fixed { .. } => editor.orbit_constraint = OrbitConstraint::Free,
152            OrbitConstraint::Free => {
153                editor.orbit_constraint = OrbitConstraint::Fixed {
154                    up: DVec3::Y,
155                    can_pass_tdc: false,
156                };
157
158                look_to.write(LookToTrigger::auto_snap_up_direction(
159                    transform.forward().as_dvec3(),
160                    entity,
161                    &transform.rotation.as_dquat(),
162                    editor.as_ref(),
163                ));
164            }
165        };
166    }
167}
168
169fn switch_direction(
170    keys: Res<ButtonInput<KeyCode>>,
171    mut look_to: MessageWriter<LookToTrigger>,
172    cam: Query<(Entity, &Transform, &PanOrbitCamera)>,
173) {
174    let (camera, transform, editor) = cam.single().unwrap();
175    if keys.just_pressed(KeyCode::Digit1) {
176        look_to.write(LookToTrigger::auto_snap_up_direction(
177            DVec3::X,
178            camera,
179            &transform.rotation.as_dquat(),
180            editor,
181        ));
182    }
183    if keys.just_pressed(KeyCode::Digit2) {
184        look_to.write(LookToTrigger::auto_snap_up_direction(
185            DVec3::Z,
186            camera,
187            &transform.rotation.as_dquat(),
188            editor,
189        ));
190    }
191    if keys.just_pressed(KeyCode::Digit3) {
192        look_to.write(LookToTrigger::auto_snap_up_direction(
193            DVec3::NEG_X,
194            camera,
195            &transform.rotation.as_dquat(),
196            editor,
197        ));
198    }
199    if keys.just_pressed(KeyCode::Digit4) {
200        look_to.write(LookToTrigger::auto_snap_up_direction(
201            DVec3::NEG_Z,
202            camera,
203            &transform.rotation.as_dquat(),
204            editor,
205        ));
206    }
207    if keys.just_pressed(KeyCode::Digit5) {
208        look_to.write(LookToTrigger::auto_snap_up_direction(
209            DVec3::Y,
210            camera,
211            &transform.rotation.as_dquat(),
212            editor,
213        ));
214    }
215    if keys.just_pressed(KeyCode::Digit6) {
216        look_to.write(LookToTrigger::auto_snap_up_direction(
217            DVec3::NEG_Y,
218            camera,
219            &transform.rotation.as_dquat(),
220            editor,
221        ));
222    }
223}
224
225fn setup_ui(mut commands: Commands, camera: Entity) {
226    let text = "\
227Left Mouse  - Pan
228Right Mouse - Orbit
229Scroll      - Zoom
230Z           - Toggle passing thought surface on minimal zoom
231P           - Toggle projection
232C           - Toggle orbit constraint
233E           - Toggle explode
2341-6         - Switch direction";
235    commands.spawn((
236        children![
237            Text::new(text),
238            (Text::new(zoom_status(false)), ZoomStatusIndicator),
239        ],
240        TextFont {
241            font_size: FontSize::Px(20.0),
242            ..default()
243        },
244        Node {
245            margin: UiRect::all(Val::Px(20.0)),
246            flex_direction: FlexDirection::Column,
247            ..Default::default()
248        },
249        Pickable::IGNORE,
250        UiTargetCamera(camera),
251    ));
252}
253
254#[derive(Component)]
255struct StartPos(f32);
256
257fn explode(
258    mut commands: Commands,
259    keys: Res<ButtonInput<KeyCode>>,
260    mut toggle: Local<Option<(bool, Instant, f32)>>,
261    mut explode_amount: Local<f32>,
262    mut redraw: MessageWriter<RequestRedraw>,
263    mut parts: Query<(Entity, &mut Transform, &Aabb, Option<&StartPos>), With<Mesh3d>>,
264    mut matls: ResMut<Assets<StandardMaterial>>,
265) {
266    let animation = Duration::from_millis(2000);
267    if keys.just_pressed(KeyCode::KeyE) {
268        let new = if let Some((last, ..)) = *toggle {
269            !last
270        } else {
271            true
272        };
273        *toggle = Some((new, Instant::now(), *explode_amount));
274    }
275    if let Some((toggled, start, start_amount)) = *toggle {
276        let goal_amount = toggled as usize as f32;
277        let t = (start.elapsed().as_secs_f32() / animation.as_secs_f32()).clamp(0.0, 1.0);
278        let progress = CubicSegment::new_bezier_easing((0.25, 0.1), (0.25, 1.0)).ease(t);
279        *explode_amount = start_amount + (goal_amount - start_amount) * progress;
280        for (part, mut transform, aabb, start) in &mut parts {
281            let start = if let Some(start) = start {
282                start.0
283            } else {
284                let start = aabb.max().y;
285                commands.entity(part).insert(StartPos(start));
286                start
287            };
288            transform.translation.y = *explode_amount * (start) * 2.0;
289        }
290        if t < 1.0 {
291            redraw.write(RequestRedraw);
292        }
293    }
294    for (_, matl) in matls.iter_mut() {
295        matl.perceptual_roughness = matl.perceptual_roughness.clamp(0.3, 1.0);
296    }
297}
298
299/// Returns the GitHub download URL for the given asset.
300///
301/// The files are expected to be in the [repository].
302///
303/// [repository]: https://github.com/bevyengine/bevy_asset_files
304fn get_web_asset_url(name: &str) -> String {
305    format!(
306        "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/{}",
307        name
308    )
309}