bevy_yoleck/
vpeol_3d.rs

1//! # Viewport Editing Overlay for 3D games.
2//!
3//! Use this module to implement simple 3D editing for 3D games.
4//!
5//! To use add the egui and Yoleck plugins to the Bevy app, as well as the plugin of this module:
6//!
7//! ```no_run
8//! # use bevy::prelude::*;
9//! # use bevy_yoleck::bevy_egui::EguiPlugin;
10//! # use bevy_yoleck::prelude::*;
11//! # use bevy_yoleck::vpeol::prelude::*;
12//! # let mut app = App::new();
13//! app.add_plugins(EguiPlugin::default());
14//! app.add_plugins(YoleckPluginForEditor);
15//! // - Use `Vpeol3dPluginForGame` instead when setting up for game.
16//! // - Use topdown is for games that utilize the XZ plane. There is also
17//! //   `Vpeol3dPluginForEditor::sidescroller` for games that mainly need the XY plane.
18//! app.add_plugins(Vpeol3dPluginForEditor::topdown());
19//! ```
20//!
21//! Add the following components to the camera entity:
22//! * [`VpeolCameraState`] in order to select and drag entities.
23//! * [`Vpeol3dCameraControl`] in order to control the camera with the mouse. This one can be
24//!   skipped if there are other means to control the camera inside the editor, or if no camera
25//!   control inside the editor is desired.
26//!
27//! ```no_run
28//! # use bevy::prelude::*;
29//! # use bevy_yoleck::vpeol::prelude::*;
30//! # let commands: Commands = panic!();
31//! commands
32//!     .spawn(Camera3d::default())
33//!     .insert(VpeolCameraState::default())
34//!     // Use a variant of the camera controls that fit the choice of editor plugin.
35//!     .insert(Vpeol3dCameraControl::topdown());
36//! ```
37//!
38//! ## Custom Camera Modes
39//!
40//! You can customize available camera modes using [`YoleckCameraChoices`]:
41//!
42//! ```no_run
43//! # use bevy::prelude::*;
44//! # use bevy_yoleck::vpeol::prelude::*;
45//! # let mut app = App::new();
46//! app.insert_resource(
47//!     YoleckCameraChoices::default()
48//!         .choice_with_transform(
49//!             "Custom Camera",
50//!             {
51//!                 let mut control = Vpeol3dCameraControl::fps();
52//!                 control.mode = Vpeol3dCameraMode::Custom(0);
53//!                 control
54//!             },
55//!             Vec3::new(10.0, 10.0, 10.0),
56//!             Vec3::ZERO,
57//!             Vec3::Y,
58//!         )
59//! );
60//!
61//! // Implement custom camera movement
62//! app.add_systems(PostUpdate, custom_camera_movement);
63//!
64//! fn custom_camera_movement(
65//!     mut cameras: Query<(&mut Transform, &Vpeol3dCameraControl)>,
66//! ) {
67//!     for (mut transform, control) in cameras.iter_mut() {
68//!         if control.mode == Vpeol3dCameraMode::Custom(0) {
69//!             // Your custom camera logic here
70//!         }
71//!     }
72//! }
73//! ```
74//!
75//! Entity selection by clicking on it is supported by just adding the plugin. To implement
76//! dragging, there are two options:
77//!
78//! 1. Add the [`Vpeol3dPosition`] Yoleck component and use it as the source of position. Gizmo
79//!    with axis knobs (X, Y, Z) are automatically added to all entities with `Vpeol3dPosition`.
80//!    Configure them using the [`Vpeol3dTranslationGizmoConfig`] resource. Optionally add
81//!    [`Vpeol3dRotation`] (edited with Euler angles) and [`Vpeol3dScale`] (edited with X, Y, Z
82//!    values) for rotation and scale support.
83//!     ```no_run
84//!     # use bevy::prelude::*;
85//!     # use bevy_yoleck::prelude::*;
86//!     # use bevy_yoleck::vpeol::prelude::*;
87//!     # use serde::{Deserialize, Serialize};
88//!     # #[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
89//!     # struct Example;
90//!     # let mut app = App::new();
91//!     app.add_yoleck_entity_type({
92//!         YoleckEntityType::new("Example")
93//!             .with::<Vpeol3dPosition>() // vpeol_3d dragging with axis knobs
94//!             .with::<Vpeol3dRotation>() // optional: rotation with egui (Euler angles)
95//!             .with::<Vpeol3dScale>() // optional: scale with egui
96//!             .with::<Example>() // entity's specific data and systems
97//!     });
98//!     ```
99//! 2. Use data passing. vpeol_3d will pass a `Vec3` to the entity being dragged:
100//!     ```no_run
101//!     # use bevy::prelude::*;
102//!     # use bevy_yoleck::prelude::*;
103//!     # use serde::{Deserialize, Serialize};
104//!     # #[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
105//!     # struct Example {
106//!     #     position: Vec3,
107//!     # }
108//!     # let mut app = App::new();
109//!     fn edit_example(mut edit: YoleckEdit<(Entity, &mut Example)>, passed_data: Res<YoleckPassedData>) {
110//!         let Ok((entity, mut example)) = edit.single_mut() else { return };
111//!         if let Some(pos) = passed_data.get::<Vec3>(entity) {
112//!             example.position = *pos;
113//!         }
114//!     }
115//!
116//!     fn populate_example(
117//!         mut populate: YoleckPopulate<&Example>,
118//!         asset_server: Res<AssetServer>
119//!     ) {
120//!         populate.populate(|_ctx, mut cmd, example| {
121//!             cmd.insert(Transform::from_translation(example.position));
122//!             cmd.insert(SceneRoot(asset_server.load("scene.glb#Scene0")));
123//!         });
124//!     }
125//!     ```
126
127use std::any::TypeId;
128
129use crate::bevy_egui::egui;
130use crate::editor_panels::YoleckPanelUi;
131use crate::exclusive_systems::{
132    YoleckEntityCreationExclusiveSystems, YoleckExclusiveSystemDirective,
133};
134use crate::vpeol::{
135    VpeolBasePlugin, VpeolCameraState, VpeolClicksOnObjectsState, VpeolDragPlane,
136    VpeolRepositionLevel, VpeolRootResolver, VpeolSystems, handle_clickable_children_system,
137    ray_intersection_with_mesh,
138};
139use crate::{
140    YoleckBelongsToLevel, YoleckDirective, YoleckEditorTopPanelSections, YoleckSchedule, prelude::*,
141};
142use bevy::camera::visibility::VisibleEntities;
143use bevy::color::palettes::css;
144use bevy::input::mouse::{MouseMotion, MouseWheel};
145use bevy::math::DVec3;
146use bevy::prelude::*;
147use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
148use bevy_egui::EguiContexts;
149use serde::{Deserialize, Serialize};
150
151/// Add the systems required for loading levels that use vpeol_3d components
152pub struct Vpeol3dPluginForGame;
153
154impl Plugin for Vpeol3dPluginForGame {
155    fn build(&self, app: &mut App) {
156        app.add_systems(
157            YoleckSchedule::OverrideCommonComponents,
158            vpeol_3d_populate_transform,
159        );
160        #[cfg(feature = "bevy_reflect")]
161        register_reflect_types(app);
162    }
163}
164
165#[cfg(feature = "bevy_reflect")]
166fn register_reflect_types(app: &mut App) {
167    app.register_type::<Vpeol3dPosition>();
168    app.register_type::<Vpeol3dRotation>();
169    app.register_type::<Vpeol3dScale>();
170    app.register_type::<Vpeol3dCameraControl>();
171}
172
173/// Add the systems required for 3D editing.
174///
175/// * 3D camera control (for cameras with [`Vpeol3dCameraControl`])
176/// * Entity selection.
177/// * Entity dragging.
178/// * Connecting nested entities.
179pub struct Vpeol3dPluginForEditor {
180    /// The plane to configure the global [`VpeolDragPlane`] resource with.
181    ///
182    /// Indiviual entities can override this with their own [`VpeolDragPlane`] component.
183    ///
184    /// It is a good idea to match this to [`Vpeol3dCameraControl::plane`].
185    pub drag_plane: InfinitePlane3d,
186}
187
188impl Vpeol3dPluginForEditor {
189    /// For sidescroller games - drag entities along the XY plane.
190    ///
191    /// Indiviual entities can override this with a [`VpeolDragPlane`] component.
192    ///
193    /// This combines well with [`Vpeol3dCameraControl::sidescroller`].
194    pub fn sidescroller() -> Self {
195        Self {
196            drag_plane: InfinitePlane3d { normal: Dir3::Z },
197        }
198    }
199
200    /// For games that are not sidescrollers - drag entities along the XZ plane.
201    ///
202    /// Indiviual entities can override this with a [`VpeolDragPlane`] component.
203    ///
204    /// This combines well with [`Vpeol3dCameraControl::topdown`].
205    pub fn topdown() -> Self {
206        Self {
207            drag_plane: InfinitePlane3d { normal: Dir3::Y },
208        }
209    }
210}
211
212impl Plugin for Vpeol3dPluginForEditor {
213    fn build(&self, app: &mut App) {
214        app.add_plugins(VpeolBasePlugin);
215        app.add_plugins(Vpeol3dPluginForGame);
216        app.insert_resource(VpeolDragPlane(self.drag_plane));
217        app.init_resource::<Vpeol3dTranslationGizmoConfig>();
218        app.init_resource::<YoleckCameraChoices>();
219
220        let camera_mode_selector = app.register_system(vpeol_3d_camera_mode_selector);
221        app.world_mut()
222            .resource_mut::<YoleckEditorTopPanelSections>()
223            .0
224            .push(camera_mode_selector);
225        let translation_gizmo_mode_selector =
226            app.register_system(vpeol_3d_translation_gizmo_mode_selector);
227        app.world_mut()
228            .resource_mut::<YoleckEditorTopPanelSections>()
229            .0
230            .push(translation_gizmo_mode_selector);
231
232        app.add_systems(
233            Update,
234            (update_camera_status_for_models,).in_set(VpeolSystems::UpdateCameraState),
235        );
236        app.add_systems(
237            PostUpdate,
238            (
239                camera_3d_wasd_movement,
240                camera_3d_move_along_plane_normal,
241                camera_3d_rotate,
242            )
243                .run_if(in_state(YoleckEditorState::EditorActive)),
244        );
245        app.add_systems(
246            Update,
247            draw_scene_gizmo.run_if(in_state(YoleckEditorState::EditorActive)),
248        );
249        app.add_systems(
250            Update,
251            (
252                ApplyDeferred,
253                handle_clickable_children_system::<With<Mesh3d>, ()>,
254                ApplyDeferred,
255            )
256                .chain()
257                .run_if(in_state(YoleckEditorState::EditorActive)),
258        );
259        app.add_yoleck_edit_system(vpeol_3d_edit_transform_group);
260        app.world_mut()
261            .resource_mut::<YoleckEntityCreationExclusiveSystems>()
262            .on_entity_creation(|queue| queue.push_back(vpeol_3d_init_position));
263        app.add_yoleck_edit_system(vpeol_3d_edit_axis_knobs);
264    }
265}
266
267fn update_camera_status_for_models(
268    mut cameras_query: Query<(&mut VpeolCameraState, &VisibleEntities)>,
269    entities_query: Query<(Entity, &GlobalTransform, &Mesh3d)>,
270    mesh_assets: Res<Assets<Mesh>>,
271    root_resolver: VpeolRootResolver,
272) {
273    for (mut camera_state, visible_entities) in cameras_query.iter_mut() {
274        let Some(cursor_ray) = camera_state.cursor_ray else {
275            continue;
276        };
277        for (entity, global_transform, mesh) in
278            entities_query.iter_many(visible_entities.iter(TypeId::of::<Mesh3d>()))
279        {
280            let Some(mesh) = mesh_assets.get(&mesh.0) else {
281                continue;
282            };
283
284            let inverse_transform = global_transform.to_matrix().inverse();
285
286            // Note: the transform may change the ray's length, which Bevy no longer supports
287            // (since version 0.13), so we keep the ray length separately and apply it later to the
288            // distance.
289            let ray_origin = inverse_transform.transform_point3(cursor_ray.origin);
290            let ray_vector = inverse_transform.transform_vector3(*cursor_ray.direction);
291            let Ok((ray_direction, ray_length_factor)) = Dir3::new_and_length(ray_vector) else {
292                continue;
293            };
294
295            let ray_in_object_coords = Ray3d {
296                origin: ray_origin,
297                direction: ray_direction,
298            };
299
300            let Some(distance) = ray_intersection_with_mesh(ray_in_object_coords, mesh) else {
301                continue;
302            };
303
304            let distance = distance / ray_length_factor;
305
306            let Some(root_entity) = root_resolver.resolve_root(entity) else {
307                continue;
308            };
309            camera_state.consider(root_entity, -distance, || cursor_ray.get_point(distance));
310        }
311    }
312}
313
314/// A single camera mode choice with its control settings and optional initial transform.
315///
316/// The `control` field contains the camera settings that will be applied when this mode is selected.
317/// The `initial_transform` field, if present, will reposition the camera when switching to this mode.
318pub struct YoleckCameraChoice {
319    /// Display name shown in the camera mode selector UI.
320    pub name: String,
321    /// Camera control settings for this mode.
322    pub control: Vpeol3dCameraControl,
323    /// Optional initial transform (position, look_at, up) to apply when switching to this mode.
324    pub initial_transform: Option<(Vec3, Vec3, Vec3)>,
325}
326
327/// Resource that defines available camera modes in the editor.
328///
329/// This allows users to customize which camera modes are available and add custom modes.
330///
331/// # Example
332///
333/// ```no_run
334/// # use bevy::prelude::*;
335/// # use bevy_yoleck::vpeol::prelude::*;
336/// # let mut app = App::new();
337/// app.insert_resource(
338///     YoleckCameraChoices::default()
339///         .choice_with_transform(
340///             "Isometric",
341///             {
342///                 let mut control = Vpeol3dCameraControl::fps();
343///                 control.mode = Vpeol3dCameraMode::Custom(2);
344///                 control.allow_rotation_while_maintaining_up = None;
345///                 control
346///             },
347///             Vec3::new(10.0, 10.0, 10.0),
348///             Vec3::ZERO,
349///             Vec3::Y,
350///         )
351/// );
352/// ```
353#[derive(Resource)]
354pub struct YoleckCameraChoices {
355    pub choices: Vec<YoleckCameraChoice>,
356}
357
358impl YoleckCameraChoices {
359    pub fn new() -> Self {
360        Self {
361            choices: Vec::new(),
362        }
363    }
364
365    /// Add a camera mode choice without initial transform.
366    ///
367    /// The camera will keep its current position when switching to this mode.
368    pub fn choice(mut self, name: impl Into<String>, control: Vpeol3dCameraControl) -> Self {
369        self.choices.push(YoleckCameraChoice {
370            name: name.into(),
371            control,
372            initial_transform: None,
373        });
374        self
375    }
376
377    /// Add a camera mode choice with initial transform.
378    ///
379    /// When switching to this mode, the camera will be repositioned according to
380    /// the provided `position`, `look_at`, and `up` parameters.
381    pub fn choice_with_transform(
382        mut self,
383        name: impl Into<String>,
384        control: Vpeol3dCameraControl,
385        position: Vec3,
386        look_at: Vec3,
387        up: Vec3,
388    ) -> Self {
389        self.choices.push(YoleckCameraChoice {
390            name: name.into(),
391            control,
392            initial_transform: Some((position, look_at, up)),
393        });
394        self
395    }
396}
397
398impl Default for YoleckCameraChoices {
399    fn default() -> Self {
400        Self::new()
401            .choice_with_transform(
402                "FPS",
403                Vpeol3dCameraControl::fps(),
404                Vec3::ZERO,
405                Vec3::NEG_Z,
406                Vec3::Y,
407            )
408            .choice_with_transform(
409                "Sidescroller",
410                Vpeol3dCameraControl::sidescroller(),
411                Vec3::new(0.0, 0.0, 10.0),
412                Vec3::ZERO,
413                Vec3::Y,
414            )
415            .choice_with_transform(
416                "Topdown",
417                Vpeol3dCameraControl::topdown(),
418                Vec3::new(0.0, 10.0, 0.0),
419                Vec3::ZERO,
420                Vec3::NEG_Z,
421            )
422    }
423}
424
425/// Camera mode identifier for type-safe camera mode comparisons.
426///
427/// Use this enum to identify which camera mode is currently active, instead of string comparisons.
428/// The built-in modes (`Fps`, `Sidescroller`, `Topdown`) are provided by default.
429/// Use `Custom(u32)` for user-defined camera modes with unique identifiers.
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
431#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
432pub enum Vpeol3dCameraMode {
433    /// FPS-style camera with full rotation freedom.
434    Fps,
435    /// Fixed camera for sidescroller games (XY plane).
436    Sidescroller,
437    /// Top-down camera for games using XZ plane.
438    Topdown,
439    /// Custom camera mode with a unique identifier.
440    Custom(u32),
441}
442
443/// Move and rotate a camera entity with the mouse while inside the editor.
444#[derive(Component, Clone)]
445#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
446pub struct Vpeol3dCameraControl {
447    /// Camera mode identifier for type-safe mode comparisons.
448    ///
449    /// Use this to identify which camera mode is active in custom camera systems.
450    pub mode: Vpeol3dCameraMode,
451    /// Defines the plane normal for mouse wheel zoom movement.
452    pub plane: InfinitePlane3d,
453    /// If `Some`, enable mouse rotation. The up direction of the camera will be the specific
454    /// direction.
455    ///
456    /// It is a good idea to match this to [`Vpeol3dPluginForEditor::drag_plane`].
457    pub allow_rotation_while_maintaining_up: Option<Dir3>,
458    /// How much to change the proximity to the plane when receiving scroll event in
459    /// `MouseScrollUnit::Line` units.
460    pub proximity_per_scroll_line: f32,
461    /// How much to change the proximity to the plane when receiving scroll event in
462    /// `MouseScrollUnit::Pixel` units.
463    pub proximity_per_scroll_pixel: f32,
464    /// Movement speed for WASD controls (units per second).
465    pub wasd_movement_speed: f32,
466    /// Mouse sensitivity for camera rotation.
467    pub mouse_sensitivity: f32,
468}
469
470impl Vpeol3dCameraControl {
471    /// Preset for FPS-style camera control with full rotation freedom.
472    ///
473    /// This mode allows complete free-look rotation with mouse and WASD movement.
474    pub fn fps() -> Self {
475        Self {
476            mode: Vpeol3dCameraMode::Fps,
477            plane: InfinitePlane3d { normal: Dir3::Y },
478            allow_rotation_while_maintaining_up: Some(Dir3::Y),
479            proximity_per_scroll_line: 2.0,
480            proximity_per_scroll_pixel: 0.01,
481            wasd_movement_speed: 10.0,
482            mouse_sensitivity: 0.003,
483        }
484    }
485
486    /// Preset for sidescroller games, where the the game world is on the XY plane.
487    ///
488    /// With this preset, the camera stays fixed looking at the scene from the side.
489    ///
490    /// This combines well with [`Vpeol3dPluginForEditor::sidescroller`].
491    pub fn sidescroller() -> Self {
492        Self {
493            mode: Vpeol3dCameraMode::Sidescroller,
494            plane: InfinitePlane3d {
495                normal: Dir3::NEG_Z,
496            },
497            allow_rotation_while_maintaining_up: None,
498            proximity_per_scroll_line: 2.0,
499            proximity_per_scroll_pixel: 0.01,
500            wasd_movement_speed: 10.0,
501            mouse_sensitivity: 0.003,
502        }
503    }
504
505    /// Preset for games where the the game world is mainly on the XZ plane (though there can still
506    /// be verticality)
507    ///
508    /// This combines well with [`Vpeol3dPluginForEditor::topdown`].
509    pub fn topdown() -> Self {
510        Self {
511            mode: Vpeol3dCameraMode::Topdown,
512            plane: InfinitePlane3d {
513                normal: Dir3::NEG_Y,
514            },
515            allow_rotation_while_maintaining_up: None,
516            proximity_per_scroll_line: 2.0,
517            proximity_per_scroll_pixel: 0.01,
518            wasd_movement_speed: 10.0,
519            mouse_sensitivity: 0.003,
520        }
521    }
522}
523
524fn camera_3d_wasd_movement(
525    mut egui_context: EguiContexts,
526    keyboard_input: Res<ButtonInput<KeyCode>>,
527    time: Res<Time>,
528    mut cameras_query: Query<(&mut Transform, &Vpeol3dCameraControl)>,
529) -> Result {
530    if egui_context.ctx_mut()?.wants_keyboard_input() {
531        return Ok(());
532    }
533
534    let mut direction = Vec3::ZERO;
535
536    if keyboard_input.pressed(KeyCode::KeyW) {
537        direction += Vec3::NEG_Z;
538    }
539    if keyboard_input.pressed(KeyCode::KeyS) {
540        direction += Vec3::Z;
541    }
542    if keyboard_input.pressed(KeyCode::KeyA) {
543        direction += Vec3::NEG_X;
544    }
545    if keyboard_input.pressed(KeyCode::KeyD) {
546        direction += Vec3::X;
547    }
548    if keyboard_input.pressed(KeyCode::KeyE) {
549        direction += Vec3::Y;
550    }
551    if keyboard_input.pressed(KeyCode::KeyQ) {
552        direction += Vec3::NEG_Y;
553    }
554
555    if direction == Vec3::ZERO {
556        return Ok(());
557    }
558
559    direction = direction.normalize_or_zero();
560
561    let speed_multiplier = if keyboard_input.pressed(KeyCode::ShiftLeft) {
562        2.0
563    } else {
564        1.0
565    };
566
567    for (mut camera_transform, camera_control) in cameras_query.iter_mut() {
568        let movement = match camera_control.mode {
569            Vpeol3dCameraMode::Sidescroller => {
570                let mut world_direction = Vec3::ZERO;
571                if keyboard_input.pressed(KeyCode::KeyW) {
572                    world_direction.y += 1.0;
573                }
574                if keyboard_input.pressed(KeyCode::KeyS) {
575                    world_direction.y -= 1.0;
576                }
577                if keyboard_input.pressed(KeyCode::KeyA) {
578                    world_direction.x -= 1.0;
579                }
580                if keyboard_input.pressed(KeyCode::KeyD) {
581                    world_direction.x += 1.0;
582                }
583                world_direction.normalize_or_zero()
584                    * camera_control.wasd_movement_speed
585                    * speed_multiplier
586                    * time.delta_secs()
587            }
588            Vpeol3dCameraMode::Topdown => {
589                let mut world_direction = Vec3::ZERO;
590                if keyboard_input.pressed(KeyCode::KeyW) {
591                    world_direction.z -= 1.0;
592                }
593                if keyboard_input.pressed(KeyCode::KeyS) {
594                    world_direction.z += 1.0;
595                }
596                if keyboard_input.pressed(KeyCode::KeyA) {
597                    world_direction.x -= 1.0;
598                }
599                if keyboard_input.pressed(KeyCode::KeyD) {
600                    world_direction.x += 1.0;
601                }
602                world_direction.normalize_or_zero()
603                    * camera_control.wasd_movement_speed
604                    * speed_multiplier
605                    * time.delta_secs()
606            }
607            _ => {
608                camera_transform.rotation
609                    * direction
610                    * camera_control.wasd_movement_speed
611                    * speed_multiplier
612                    * time.delta_secs()
613            }
614        };
615
616        camera_transform.translation += movement;
617    }
618    Ok(())
619}
620
621fn camera_3d_move_along_plane_normal(
622    mut egui_context: EguiContexts,
623    mut cameras_query: Query<(&mut Transform, &Vpeol3dCameraControl)>,
624    mut wheel_events_reader: MessageReader<MouseWheel>,
625) -> Result {
626    if egui_context.ctx_mut()?.is_pointer_over_area() {
627        return Ok(());
628    }
629
630    for (mut camera_transform, camera_control) in cameras_query.iter_mut() {
631        let zoom_amount: f32 = wheel_events_reader
632            .read()
633            .map(|wheel_event| match wheel_event.unit {
634                bevy::input::mouse::MouseScrollUnit::Line => {
635                    wheel_event.y * camera_control.proximity_per_scroll_line
636                }
637                bevy::input::mouse::MouseScrollUnit::Pixel => {
638                    wheel_event.y * camera_control.proximity_per_scroll_pixel
639                }
640            })
641            .sum();
642
643        if zoom_amount == 0.0 {
644            continue;
645        }
646
647        camera_transform.translation += zoom_amount * *camera_control.plane.normal;
648    }
649    Ok(())
650}
651
652fn draw_scene_gizmo(
653    mut egui_context: EguiContexts,
654    mut cameras_query: Query<&mut Transform, With<VpeolCameraState>>,
655    mouse_buttons: Res<ButtonInput<MouseButton>>,
656    mut first_frame_skipped: Local<bool>,
657    editor_viewport: Res<crate::editor_window::YoleckEditorViewportRect>,
658) -> Result {
659    if !*first_frame_skipped {
660        *first_frame_skipped = true;
661        return Ok(());
662    }
663
664    let ctx = egui_context.ctx_mut()?;
665
666    if !ctx.is_using_pointer() && ctx.input(|i| i.viewport_rect().width() == 0.0) {
667        return Ok(());
668    }
669
670    let Ok(mut camera_transform) = cameras_query.single_mut() else {
671        return Ok(());
672    };
673
674    let screen_rect = editor_viewport
675        .rect
676        .unwrap_or_else(|| ctx.input(|i| i.viewport_rect()));
677
678    if screen_rect.width() == 0.0 || screen_rect.height() == 0.0 {
679        return Ok(());
680    }
681
682    let gizmo_size = 60.0;
683    let axis_length = 25.0;
684    let margin = 20.0;
685    let click_radius = 10.0;
686
687    let center = egui::Pos2::new(
688        screen_rect.max.x - margin - gizmo_size / 2.0,
689        screen_rect.min.y + margin + gizmo_size / 2.0,
690    );
691
692    let camera_rotation = camera_transform.rotation;
693    let inv_rotation = camera_rotation.inverse();
694
695    let world_x = inv_rotation * Vec3::X;
696    let world_y = inv_rotation * Vec3::Y;
697    let world_z = inv_rotation * Vec3::Z;
698
699    let to_screen = |v: Vec3| -> egui::Pos2 {
700        let perspective_scale = 1.0 / (1.0 - v.z * 0.3);
701        let screen_x = v.x * axis_length * perspective_scale;
702        let screen_y = v.y * axis_length * perspective_scale;
703
704        let len = (screen_x * screen_x + screen_y * screen_y).sqrt();
705        let min_len = 8.0;
706        let (screen_x, screen_y) = if len < min_len && len > 0.001 {
707            let scale = min_len / len;
708            (screen_x * scale, screen_y * scale)
709        } else {
710            (screen_x, screen_y)
711        };
712
713        egui::Pos2::new(center.x + screen_x, center.y - screen_y)
714    };
715
716    let x_pos = to_screen(world_x);
717    let x_neg = to_screen(-world_x);
718    let y_pos = to_screen(world_y);
719    let y_neg = to_screen(-world_y);
720    let z_pos = to_screen(world_z);
721    let z_neg = to_screen(-world_z);
722
723    let cursor_pos = ctx.input(|i| i.pointer.hover_pos());
724    let gizmo_rect = egui::Rect::from_center_size(center, egui::Vec2::splat(gizmo_size));
725
726    if let Some(cursor) = cursor_pos
727        && mouse_buttons.just_pressed(MouseButton::Left)
728        && gizmo_rect.contains(cursor)
729    {
730        let distances = [
731            (cursor.distance(x_pos), Vec3::NEG_X, Vec3::Y),
732            (cursor.distance(x_neg), Vec3::X, Vec3::Y),
733            (cursor.distance(y_pos), Vec3::NEG_Y, Vec3::Z),
734            (cursor.distance(y_neg), Vec3::Y, Vec3::Z),
735            (cursor.distance(z_pos), Vec3::NEG_Z, Vec3::Y),
736            (cursor.distance(z_neg), Vec3::Z, Vec3::Y),
737        ];
738
739        if let Some((_, forward, up)) = distances
740            .iter()
741            .filter(|(d, _, _)| *d < click_radius)
742            .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
743        {
744            camera_transform.look_to(*forward, *up);
745        }
746    }
747
748    #[derive(Clone, Copy)]
749    struct AxisData {
750        depth: f32,
751        color_bright: egui::Color32,
752        color_dim: egui::Color32,
753        pos_end: egui::Pos2,
754        neg_end: egui::Pos2,
755        world_dir: Vec3,
756    }
757
758    let mut axes = vec![
759        AxisData {
760            depth: world_x.z.abs(),
761            color_bright: egui::Color32::from_rgb(230, 60, 60),
762            color_dim: egui::Color32::from_rgb(120, 50, 50),
763            pos_end: x_pos,
764            neg_end: x_neg,
765            world_dir: world_x,
766        },
767        AxisData {
768            depth: world_y.z.abs(),
769            color_bright: egui::Color32::from_rgb(60, 230, 60),
770            color_dim: egui::Color32::from_rgb(50, 120, 50),
771            pos_end: y_pos,
772            neg_end: y_neg,
773            world_dir: world_y,
774        },
775        AxisData {
776            depth: world_z.z.abs(),
777            color_bright: egui::Color32::from_rgb(60, 120, 230),
778            color_dim: egui::Color32::from_rgb(50, 70, 120),
779            pos_end: z_pos,
780            neg_end: z_neg,
781            world_dir: world_z,
782        },
783    ];
784    axes.sort_by(|a, b| b.depth.partial_cmp(&a.depth).unwrap());
785
786    let painter = ctx.layer_painter(egui::LayerId::new(
787        egui::Order::Foreground,
788        egui::Id::new("scene_gizmo"),
789    ));
790
791    painter.circle_filled(
792        center,
793        gizmo_size / 2.0,
794        egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200),
795    );
796
797    let stroke_bright = 3.0;
798    let stroke_dim = 2.0;
799    let cone_radius_bright = 5.0;
800    let cone_radius_dim = 3.5;
801    let cone_length = 8.0;
802
803    for axis in &axes {
804        let (front_end, front_color, back_end, back_color) = if axis.world_dir.z >= 0.0 {
805            (
806                axis.pos_end,
807                axis.color_bright,
808                axis.neg_end,
809                axis.color_dim,
810            )
811        } else {
812            (
813                axis.neg_end,
814                axis.color_dim,
815                axis.pos_end,
816                axis.color_bright,
817            )
818        };
819
820        let back_dir = (back_end - center).normalized();
821        let back_line_end = back_end - back_dir * cone_length;
822        painter.line_segment(
823            [center, back_line_end],
824            egui::Stroke::new(stroke_dim, back_color),
825        );
826
827        let back_perp = egui::Vec2::new(-back_dir.y, back_dir.x);
828        let back_cone_base = back_end - back_dir * cone_length;
829        let back_cone = vec![
830            back_end,
831            back_cone_base + back_perp * cone_radius_dim,
832            back_cone_base - back_perp * cone_radius_dim,
833        ];
834        painter.add(egui::Shape::convex_polygon(
835            back_cone,
836            back_color,
837            egui::Stroke::NONE,
838        ));
839
840        let front_dir = (front_end - center).normalized();
841        let front_line_end = front_end - front_dir * cone_length;
842        painter.line_segment(
843            [center, front_line_end],
844            egui::Stroke::new(stroke_bright, front_color),
845        );
846
847        let front_perp = egui::Vec2::new(-front_dir.y, front_dir.x);
848        let front_cone_base = front_end - front_dir * cone_length;
849        let front_cone = vec![
850            front_end,
851            front_cone_base + front_perp * cone_radius_bright,
852            front_cone_base - front_perp * cone_radius_bright,
853        ];
854        painter.add(egui::Shape::convex_polygon(
855            front_cone,
856            front_color,
857            egui::Stroke::NONE,
858        ));
859    }
860
861    let label_offset = 12.0;
862    let font_id = egui::FontId::proportional(12.0);
863
864    let axis_labels = [
865        ("X", x_pos, egui::Color32::from_rgb(230, 60, 60), world_x.z),
866        ("Y", y_pos, egui::Color32::from_rgb(60, 230, 60), world_y.z),
867        ("Z", z_pos, egui::Color32::from_rgb(60, 120, 230), world_z.z),
868    ];
869
870    for (label, pos, color, depth) in axis_labels {
871        let dir = (pos - center).normalized();
872        let label_pos = pos + dir * label_offset;
873        let alpha = if depth >= 0.0 { 255 } else { 120 };
874        let label_color =
875            egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
876        painter.text(
877            label_pos,
878            egui::Align2::CENTER_CENTER,
879            label,
880            font_id.clone(),
881            label_color,
882        );
883    }
884
885    Ok(())
886}
887
888fn camera_3d_rotate(
889    mut egui_context: EguiContexts,
890    mouse_buttons: Res<ButtonInput<MouseButton>>,
891    mut cameras_query: Query<(&mut Transform, &Vpeol3dCameraControl)>,
892    mut mouse_motion_reader: MessageReader<MouseMotion>,
893    mut cursor_options: Query<&mut CursorOptions, With<PrimaryWindow>>,
894    mut is_rotating: Local<bool>,
895) -> Result {
896    let Ok(mut cursor) = cursor_options.single_mut() else {
897        return Ok(());
898    };
899
900    if mouse_buttons.just_pressed(MouseButton::Right) {
901        if egui_context.ctx_mut()?.is_pointer_over_area() {
902            return Ok(());
903        }
904
905        let has_rotatable_camera = cameras_query
906            .iter()
907            .any(|(_, control)| control.allow_rotation_while_maintaining_up.is_some());
908
909        if has_rotatable_camera {
910            cursor.grab_mode = CursorGrabMode::Locked;
911            cursor.visible = false;
912            *is_rotating = true;
913        }
914    }
915
916    if mouse_buttons.just_released(MouseButton::Right) {
917        cursor.grab_mode = CursorGrabMode::None;
918        cursor.visible = true;
919        *is_rotating = false;
920    }
921
922    if !*is_rotating {
923        return Ok(());
924    }
925
926    let mut delta = Vec2::ZERO;
927    for motion in mouse_motion_reader.read() {
928        delta += motion.delta;
929    }
930
931    if delta == Vec2::ZERO {
932        return Ok(());
933    }
934
935    for (mut camera_transform, camera_control) in cameras_query.iter_mut() {
936        let Some(maintaining_up) = camera_control.allow_rotation_while_maintaining_up else {
937            continue;
938        };
939
940        let yaw = -delta.x * camera_control.mouse_sensitivity;
941        let pitch = -delta.y * camera_control.mouse_sensitivity;
942
943        let yaw_rotation = Quat::from_axis_angle(*maintaining_up, yaw);
944        camera_transform.rotation = yaw_rotation * camera_transform.rotation;
945
946        let right = camera_transform.right();
947        let pitch_rotation = Quat::from_axis_angle(*right, pitch);
948        camera_transform.rotation = pitch_rotation * camera_transform.rotation;
949
950        let new_forward = camera_transform.forward();
951        camera_transform.look_to(*new_forward, *maintaining_up);
952    }
953    Ok(())
954}
955
956pub fn vpeol_3d_translation_gizmo_mode_selector(
957    mut ui: ResMut<YoleckPanelUi>,
958    mut config: ResMut<Vpeol3dTranslationGizmoConfig>,
959) -> Result {
960    let ui = &mut **ui;
961    ui.add_space(ui.available_width());
962
963    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
964        ui.radio_value(
965            &mut config.mode,
966            Vpeol3dTranslationGizmoMode::Local,
967            "Local",
968        );
969        ui.radio_value(
970            &mut config.mode,
971            Vpeol3dTranslationGizmoMode::World,
972            "World",
973        );
974        ui.label("Gizmo:");
975    });
976
977    Ok(())
978}
979
980pub fn vpeol_3d_camera_mode_selector(
981    mut ui: ResMut<YoleckPanelUi>,
982    mut query: Query<(&mut Vpeol3dCameraControl, &mut Transform)>,
983    choices: Res<YoleckCameraChoices>,
984) -> Result {
985    if let Ok((mut camera_control, mut camera_transform)) = query.single_mut() {
986        let old_mode = camera_control.mode;
987
988        let current_choice = choices
989            .choices
990            .iter()
991            .find(|c| c.control.mode == camera_control.mode);
992        let selected_text = current_choice.map(|c| c.name.as_str()).unwrap_or("Unknown");
993
994        egui::ComboBox::from_id_salt("camera_mode_selector")
995            .selected_text(selected_text)
996            .show_ui(&mut ui, |ui| {
997                for choice in choices.choices.iter() {
998                    ui.selectable_value(
999                        &mut camera_control.mode,
1000                        choice.control.mode,
1001                        &choice.name,
1002                    );
1003                }
1004            });
1005
1006        if old_mode != camera_control.mode
1007            && let Some(choice) = choices
1008                .choices
1009                .iter()
1010                .find(|c| c.control.mode == camera_control.mode)
1011        {
1012            *camera_control = choice.control.clone();
1013
1014            if let Some((position, look_at, up)) = choice.initial_transform {
1015                camera_transform.translation = position;
1016                camera_transform.look_at(look_at, up);
1017            }
1018        }
1019    }
1020
1021    Ok(())
1022}
1023
1024/// A position component that's edited and populated by vpeol_3d.
1025///
1026/// Editing is done with egui, or by dragging the entity on a [`VpeolDragPlane`]  that passes
1027/// through the entity.
1028#[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
1029#[serde(transparent)]
1030#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
1031pub struct Vpeol3dPosition(pub Vec3);
1032
1033/// Add this to an entity with [`Vpeol3dPosition`] to force it to be on a specific plane while
1034/// editing.
1035///
1036/// This is useful for 2D games that use 3D graphics but don't want to allow free positioning of
1037/// entities on all axes.
1038///
1039/// Note that this is not a [`YoleckComponent`]. Do not add it with
1040/// [`insert_on_init_during_editor`](YoleckEntityType::with). The best way to add it is by using
1041/// [`insert_on_init_during_editor`](YoleckEntityType::insert_on_init_during_editor) which also
1042/// allows setting the data.
1043#[derive(Component)]
1044pub struct Vpeol3dSnapToPlane {
1045    pub normal: Dir3,
1046    /// Offset of the plane from the origin of axes
1047    pub offset: f32,
1048}
1049
1050#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1051pub enum Vpeol3dTranslationGizmoMode {
1052    World,
1053    Local,
1054}
1055
1056#[derive(Resource)]
1057pub struct Vpeol3dTranslationGizmoConfig {
1058    pub knob_distance: f32,
1059    pub knob_scale: f32,
1060    pub mode: Vpeol3dTranslationGizmoMode,
1061}
1062
1063impl Default for Vpeol3dTranslationGizmoConfig {
1064    fn default() -> Self {
1065        Self {
1066            knob_distance: 2.0,
1067            knob_scale: 0.5,
1068            mode: Vpeol3dTranslationGizmoMode::World,
1069        }
1070    }
1071}
1072
1073/// A rotation component that's edited and populated by vpeol_3d.
1074///
1075/// Editing is done with egui using Euler angles (X, Y, Z in degrees).
1076#[derive(Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
1077#[serde(transparent)]
1078#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
1079pub struct Vpeol3dRotation(pub Quat);
1080
1081impl Default for Vpeol3dRotation {
1082    fn default() -> Self {
1083        Self(Quat::IDENTITY)
1084    }
1085}
1086
1087/// A scale component that's edited and populated by vpeol_3d.
1088///
1089/// Editing is done with egui using separate drag values for X, Y, Z axes.
1090#[derive(Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
1091#[serde(transparent)]
1092#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
1093pub struct Vpeol3dScale(pub Vec3);
1094
1095impl Default for Vpeol3dScale {
1096    fn default() -> Self {
1097        Self(Vec3::ONE)
1098    }
1099}
1100
1101enum CommonDragPlane {
1102    NotDecidedYet,
1103    WithNormal(Vec3),
1104    NoSharedPlane,
1105}
1106
1107impl CommonDragPlane {
1108    fn consider(&mut self, normal: Vec3) {
1109        *self = match self {
1110            CommonDragPlane::NotDecidedYet => CommonDragPlane::WithNormal(normal),
1111            CommonDragPlane::WithNormal(current_normal) => {
1112                if *current_normal == normal {
1113                    CommonDragPlane::WithNormal(normal)
1114                } else {
1115                    CommonDragPlane::NoSharedPlane
1116                }
1117            }
1118            CommonDragPlane::NoSharedPlane => CommonDragPlane::NoSharedPlane,
1119        }
1120    }
1121
1122    fn shared_normal(&self) -> Option<Vec3> {
1123        if let CommonDragPlane::WithNormal(normal) = self {
1124            Some(*normal)
1125        } else {
1126            None
1127        }
1128    }
1129}
1130
1131fn vpeol_3d_edit_transform_group(
1132    mut ui: ResMut<YoleckUi>,
1133    position_edit: YoleckEdit<(
1134        Entity,
1135        &mut Vpeol3dPosition,
1136        Option<&VpeolDragPlane>,
1137        Option<&Vpeol3dSnapToPlane>,
1138    )>,
1139    rotation_edit: YoleckEdit<&mut Vpeol3dRotation>,
1140    scale_edit: YoleckEdit<&mut Vpeol3dScale>,
1141    global_drag_plane: Res<VpeolDragPlane>,
1142    passed_data: Res<YoleckPassedData>,
1143) {
1144    let has_any = !position_edit.is_empty() || !rotation_edit.is_empty() || !scale_edit.is_empty();
1145    if !has_any {
1146        return;
1147    }
1148
1149    ui.group(|ui| {
1150        ui.label(egui::RichText::new("Transform").strong());
1151        ui.separator();
1152
1153        vpeol_3d_edit_position_impl(ui, position_edit, &global_drag_plane, &passed_data);
1154        vpeol_3d_edit_rotation_impl(ui, rotation_edit);
1155        vpeol_3d_edit_scale_impl(ui, scale_edit);
1156    });
1157}
1158
1159fn vpeol_3d_edit_position_impl(
1160    ui: &mut egui::Ui,
1161    mut edit: YoleckEdit<(
1162        Entity,
1163        &mut Vpeol3dPosition,
1164        Option<&VpeolDragPlane>,
1165        Option<&Vpeol3dSnapToPlane>,
1166    )>,
1167    global_drag_plane: &VpeolDragPlane,
1168    passed_data: &YoleckPassedData,
1169) {
1170    if edit.is_empty() || edit.has_nonmatching() {
1171        return;
1172    }
1173    let mut average = DVec3::ZERO;
1174    let mut num_entities = 0;
1175    let mut transition = Vec3::ZERO;
1176
1177    let mut common_drag_plane = CommonDragPlane::NotDecidedYet;
1178
1179    for (entity, position, drag_plane, _) in edit.iter_matching() {
1180        let VpeolDragPlane(drag_plane) = drag_plane.unwrap_or(global_drag_plane);
1181        common_drag_plane.consider(*drag_plane.normal);
1182
1183        if let Some(pos) = passed_data.get::<Vec3>(entity) {
1184            transition = *pos - position.0;
1185        }
1186        average += position.0.as_dvec3();
1187        num_entities += 1;
1188    }
1189    average /= num_entities as f64;
1190
1191    if common_drag_plane.shared_normal().is_none() {
1192        transition = Vec3::ZERO;
1193        ui.label(
1194            egui::RichText::new("Drag plane differs - cannot drag together")
1195                .color(egui::Color32::RED),
1196        );
1197    }
1198    ui.horizontal(|ui| {
1199        let mut new_average = average;
1200
1201        ui.add(egui::Label::new("Position"));
1202        ui.add(egui::DragValue::new(&mut new_average.x).prefix("X:"));
1203        ui.add(egui::DragValue::new(&mut new_average.y).prefix("Y:"));
1204        ui.add(egui::DragValue::new(&mut new_average.z).prefix("Z:"));
1205
1206        transition += (new_average - average).as_vec3();
1207    });
1208
1209    if transition.is_finite() && transition != Vec3::ZERO {
1210        for (_, mut position, _, snap) in edit.iter_matching_mut() {
1211            position.0 += transition;
1212            if let Some(snap) = snap {
1213                let displacement = position.0.project_onto(*snap.normal);
1214                position.0 += snap.offset * snap.normal - displacement;
1215            }
1216        }
1217    }
1218}
1219
1220fn vpeol_3d_edit_rotation_impl(ui: &mut egui::Ui, mut edit: YoleckEdit<&mut Vpeol3dRotation>) {
1221    if edit.is_empty() || edit.has_nonmatching() {
1222        return;
1223    }
1224
1225    let mut average_euler = Vec3::ZERO;
1226    let mut num_entities = 0;
1227
1228    for rotation in edit.iter_matching() {
1229        let (x, y, z) = rotation.0.to_euler(EulerRot::XYZ);
1230        average_euler += Vec3::new(x, y, z);
1231        num_entities += 1;
1232    }
1233    average_euler /= num_entities as f32;
1234
1235    ui.horizontal(|ui| {
1236        let mut new_euler = average_euler;
1237        let mut x_deg = new_euler.x.to_degrees();
1238        let mut y_deg = new_euler.y.to_degrees();
1239        let mut z_deg = new_euler.z.to_degrees();
1240
1241        ui.add(egui::Label::new("Rotation"));
1242        ui.add(
1243            egui::DragValue::new(&mut x_deg)
1244                .prefix("X:")
1245                .speed(1.0)
1246                .suffix("°"),
1247        );
1248        ui.add(
1249            egui::DragValue::new(&mut y_deg)
1250                .prefix("Y:")
1251                .speed(1.0)
1252                .suffix("°"),
1253        );
1254        ui.add(
1255            egui::DragValue::new(&mut z_deg)
1256                .prefix("Z:")
1257                .speed(1.0)
1258                .suffix("°"),
1259        );
1260
1261        new_euler.x = x_deg.to_radians();
1262        new_euler.y = y_deg.to_radians();
1263        new_euler.z = z_deg.to_radians();
1264
1265        let transition = new_euler - average_euler;
1266
1267        if transition.is_finite() && transition != Vec3::ZERO {
1268            for mut rotation in edit.iter_matching_mut() {
1269                let (x, y, z) = rotation.0.to_euler(EulerRot::XYZ);
1270                rotation.0 = Quat::from_euler(
1271                    EulerRot::XYZ,
1272                    x + transition.x,
1273                    y + transition.y,
1274                    z + transition.z,
1275                )
1276            }
1277        }
1278    });
1279}
1280
1281fn vpeol_3d_edit_scale_impl(ui: &mut egui::Ui, mut edit: YoleckEdit<&mut Vpeol3dScale>) {
1282    if edit.is_empty() || edit.has_nonmatching() {
1283        return;
1284    }
1285    let mut average = DVec3::ZERO;
1286    let mut num_entities = 0;
1287
1288    for scale in edit.iter_matching() {
1289        average += scale.0.as_dvec3();
1290        num_entities += 1;
1291    }
1292    average /= num_entities as f64;
1293
1294    ui.horizontal(|ui| {
1295        let mut new_average = average;
1296
1297        ui.add(egui::Label::new("Scale"));
1298        ui.vertical(|ui| {
1299            ui.centered_and_justified(|ui| {
1300                let axis_average = (average.x + average.y + average.z) / 3.0;
1301                let mut new_axis_average = axis_average;
1302                if ui
1303                    .add(egui::DragValue::new(&mut new_axis_average).speed(0.01))
1304                    .dragged()
1305                {
1306                    // Use difference instead of ration to avoid problems when reaching/crossing
1307                    // the zero.
1308                    let diff = new_axis_average - axis_average;
1309                    new_average.x += diff;
1310                    new_average.y += diff;
1311                    new_average.z += diff;
1312                }
1313            });
1314            ui.horizontal(|ui| {
1315                ui.add(
1316                    egui::DragValue::new(&mut new_average.x)
1317                        .prefix("X:")
1318                        .speed(0.01),
1319                );
1320                ui.add(
1321                    egui::DragValue::new(&mut new_average.y)
1322                        .prefix("Y:")
1323                        .speed(0.01),
1324                );
1325                ui.add(
1326                    egui::DragValue::new(&mut new_average.z)
1327                        .prefix("Z:")
1328                        .speed(0.01),
1329                );
1330            });
1331        });
1332
1333        let transition = (new_average - average).as_vec3();
1334
1335        if transition.is_finite() && transition != Vec3::ZERO {
1336            for mut scale in edit.iter_matching_mut() {
1337                scale.0 += transition;
1338            }
1339        }
1340    });
1341}
1342
1343fn vpeol_3d_init_position(
1344    mut egui_context: EguiContexts,
1345    ui: Res<YoleckUi>,
1346    mut edit: YoleckEdit<(&mut Vpeol3dPosition, Option<&VpeolDragPlane>)>,
1347    global_drag_plane: Res<VpeolDragPlane>,
1348    cameras_query: Query<&VpeolCameraState>,
1349    mouse_buttons: Res<ButtonInput<MouseButton>>,
1350) -> YoleckExclusiveSystemDirective {
1351    let Ok((mut position, drag_plane)) = edit.single_mut() else {
1352        return YoleckExclusiveSystemDirective::Finished;
1353    };
1354
1355    let Some(cursor_ray) = cameras_query
1356        .iter()
1357        .find_map(|camera_state| camera_state.cursor_ray)
1358    else {
1359        return YoleckExclusiveSystemDirective::Listening;
1360    };
1361
1362    let VpeolDragPlane(drag_plane) = drag_plane.unwrap_or(global_drag_plane.as_ref());
1363    if let Some(distance_to_plane) =
1364        cursor_ray.intersect_plane(position.0, InfinitePlane3d::new(*drag_plane.normal))
1365    {
1366        position.0 = cursor_ray.get_point(distance_to_plane);
1367    };
1368
1369    if egui_context.ctx_mut().unwrap().is_pointer_over_area() || ui.ctx().is_pointer_over_area() {
1370        return YoleckExclusiveSystemDirective::Listening;
1371    }
1372
1373    if mouse_buttons.just_released(MouseButton::Left) {
1374        return YoleckExclusiveSystemDirective::Finished;
1375    }
1376
1377    YoleckExclusiveSystemDirective::Listening
1378}
1379
1380#[derive(Clone, Copy)]
1381struct AxisKnobData {
1382    axis: Vec3,
1383    drag_plane_normal: Dir3,
1384}
1385
1386#[allow(clippy::type_complexity, clippy::too_many_arguments)]
1387fn vpeol_3d_edit_axis_knobs(
1388    mut edit: YoleckEdit<(
1389        Entity,
1390        &GlobalTransform,
1391        &Vpeol3dPosition,
1392        Option<&Vpeol3dRotation>,
1393    )>,
1394    translation_gizmo_config: Res<Vpeol3dTranslationGizmoConfig>,
1395    mut knobs: YoleckKnobs,
1396    mut mesh_assets: ResMut<Assets<Mesh>>,
1397    mut material_assets: ResMut<Assets<StandardMaterial>>,
1398    mut cached_assets: Local<
1399        Option<(
1400            Handle<Mesh>,
1401            Handle<Mesh>,
1402            [Handle<StandardMaterial>; 3],
1403            [Handle<StandardMaterial>; 3],
1404        )>,
1405    >,
1406    mut directives_writer: MessageWriter<YoleckDirective>,
1407    cameras_query: Query<(&GlobalTransform, &VpeolCameraState)>,
1408) {
1409    if edit.is_empty() || edit.has_nonmatching() {
1410        return;
1411    }
1412
1413    let (camera_position, dragged_entity) = cameras_query
1414        .iter()
1415        .next()
1416        .map(|(t, state)| {
1417            let dragged = match &state.clicks_on_objects_state {
1418                VpeolClicksOnObjectsState::BeingDragged { entity, .. } => Some(*entity),
1419                _ => None,
1420            };
1421            (t.translation(), dragged)
1422        })
1423        .unwrap_or((Vec3::ZERO, None));
1424
1425    let (cone_mesh, line_mesh, materials, materials_active) =
1426        cached_assets.get_or_insert_with(|| {
1427            (
1428                mesh_assets.add(Mesh::from(Cone {
1429                    radius: 0.5,
1430                    height: 1.0,
1431                })),
1432                mesh_assets.add(Mesh::from(Cylinder {
1433                    radius: 0.15,
1434                    half_height: 0.5,
1435                })),
1436                [
1437                    material_assets.add(StandardMaterial {
1438                        base_color: Color::from(css::RED),
1439                        unlit: true,
1440                        ..default()
1441                    }),
1442                    material_assets.add(StandardMaterial {
1443                        base_color: Color::from(css::GREEN),
1444                        unlit: true,
1445                        ..default()
1446                    }),
1447                    material_assets.add(StandardMaterial {
1448                        base_color: Color::from(css::BLUE),
1449                        unlit: true,
1450                        ..default()
1451                    }),
1452                ],
1453                [
1454                    material_assets.add(StandardMaterial {
1455                        base_color: Color::linear_rgb(1.0, 0.5, 0.5),
1456                        unlit: true,
1457                        ..default()
1458                    }),
1459                    material_assets.add(StandardMaterial {
1460                        base_color: Color::linear_rgb(0.5, 1.0, 0.5),
1461                        unlit: true,
1462                        ..default()
1463                    }),
1464                    material_assets.add(StandardMaterial {
1465                        base_color: Color::linear_rgb(0.5, 0.5, 1.0),
1466                        unlit: true,
1467                        ..default()
1468                    }),
1469                ],
1470            )
1471        });
1472
1473    let world_axes = [
1474        AxisKnobData {
1475            axis: Vec3::X,
1476            drag_plane_normal: Dir3::Z,
1477        },
1478        AxisKnobData {
1479            axis: Vec3::Y,
1480            drag_plane_normal: Dir3::X,
1481        },
1482        AxisKnobData {
1483            axis: Vec3::Z,
1484            drag_plane_normal: Dir3::Y,
1485        },
1486    ];
1487
1488    for (entity, global_transform, _, rotation) in edit.iter_matching() {
1489        let entity_position = global_transform.translation();
1490        let entity_scale = global_transform.to_scale_rotation_translation().0;
1491        let entity_radius = entity_scale.max_element();
1492
1493        let distance_to_camera = (camera_position - entity_position).length();
1494        let distance_scale = (distance_to_camera / 40.0).max(1.0);
1495
1496        let axes = match translation_gizmo_config.mode {
1497            Vpeol3dTranslationGizmoMode::World => world_axes,
1498            Vpeol3dTranslationGizmoMode::Local => {
1499                let rot = if let Some(Vpeol3dRotation(euler_angles)) = rotation {
1500                    Quat::from_euler(
1501                        EulerRot::XYZ,
1502                        euler_angles.x,
1503                        euler_angles.y,
1504                        euler_angles.z,
1505                    )
1506                } else {
1507                    Quat::IDENTITY
1508                };
1509
1510                let local_x = (rot * Vec3::X).normalize();
1511                let local_y = (rot * Vec3::Y).normalize();
1512                let local_z = (rot * Vec3::Z).normalize();
1513
1514                [
1515                    AxisKnobData {
1516                        axis: local_x,
1517                        drag_plane_normal: Dir3::new_unchecked(local_z),
1518                    },
1519                    AxisKnobData {
1520                        axis: local_y,
1521                        drag_plane_normal: Dir3::new_unchecked(local_x),
1522                    },
1523                    AxisKnobData {
1524                        axis: local_z,
1525                        drag_plane_normal: Dir3::new_unchecked(local_y),
1526                    },
1527                ]
1528            }
1529        };
1530
1531        for (axis_idx, axis_data) in axes.iter().enumerate() {
1532            let knob_name = match axis_idx {
1533                0 => "vpeol-3d-axis-knob-x",
1534                1 => "vpeol-3d-axis-knob-y",
1535                _ => "vpeol-3d-axis-knob-z",
1536            };
1537
1538            let line_name = match axis_idx {
1539                0 => "vpeol-3d-axis-line-x",
1540                1 => "vpeol-3d-axis-line-y",
1541                _ => "vpeol-3d-axis-line-z",
1542            };
1543
1544            let scaled_knob_scale = translation_gizmo_config.knob_scale * distance_scale;
1545            let base_distance = translation_gizmo_config.knob_distance + entity_radius;
1546            let scaled_distance = base_distance * (1.0 + (distance_scale - 1.0) * 0.3);
1547
1548            let knob_offset = scaled_distance * axis_data.axis;
1549            let knob_position = entity_position + knob_offset;
1550            let knob_transform = Transform {
1551                translation: knob_position,
1552                rotation: Quat::from_rotation_arc(Vec3::Y, axis_data.axis),
1553                scale: scaled_knob_scale * Vec3::ONE,
1554            };
1555
1556            let line_length = scaled_distance - scaled_knob_scale * 0.5;
1557            let line_center = entity_position + axis_data.axis * line_length * 0.5;
1558            let line_transform = Transform {
1559                translation: line_center,
1560                rotation: Quat::from_rotation_arc(Vec3::Y, axis_data.axis),
1561                scale: Vec3::new(scaled_knob_scale, line_length, scaled_knob_scale),
1562            };
1563
1564            let line_knob = knobs.knob((entity, line_name));
1565            let line_knob_id = line_knob.cmd.id();
1566            drop(line_knob);
1567
1568            let knob = knobs.knob((entity, knob_name));
1569            let knob_id = knob.cmd.id();
1570            let passed_pos = knob.get_passed_data::<Vec3>().copied();
1571            drop(knob);
1572
1573            let is_active = dragged_entity == Some(line_knob_id) || dragged_entity == Some(knob_id);
1574
1575            let material = if is_active {
1576                &materials_active[axis_idx]
1577            } else {
1578                &materials[axis_idx]
1579            };
1580
1581            let mut line_knob = knobs.knob((entity, line_name));
1582            line_knob.cmd.insert((
1583                Mesh3d(line_mesh.clone()),
1584                MeshMaterial3d(material.clone()),
1585                line_transform,
1586                GlobalTransform::from(line_transform),
1587            ));
1588
1589            let mut knob = knobs.knob((entity, knob_name));
1590            knob.cmd.insert(VpeolDragPlane(InfinitePlane3d {
1591                normal: axis_data.drag_plane_normal,
1592            }));
1593            knob.cmd.insert((
1594                Mesh3d(cone_mesh.clone()),
1595                MeshMaterial3d(material.clone()),
1596                knob_transform,
1597                GlobalTransform::from(knob_transform),
1598            ));
1599
1600            if let Some(pos) = passed_pos {
1601                let vector_from_entity = pos - knob_offset - entity_position;
1602                let along_axis = vector_from_entity.dot(axis_data.axis);
1603                let new_position = entity_position + along_axis * axis_data.axis;
1604                directives_writer.write(YoleckDirective::pass_to_entity(entity, new_position));
1605            }
1606        }
1607    }
1608}
1609
1610fn vpeol_3d_populate_transform(
1611    mut populate: YoleckPopulate<(
1612        &Vpeol3dPosition,
1613        Option<&Vpeol3dRotation>,
1614        Option<&Vpeol3dScale>,
1615        &YoleckBelongsToLevel,
1616    )>,
1617    levels_query: Query<&VpeolRepositionLevel>,
1618) {
1619    populate.populate(
1620        |_ctx, mut cmd, (position, rotation, scale, belongs_to_level)| {
1621            let mut transform = Transform::from_translation(position.0);
1622            if let Some(Vpeol3dRotation(quat)) = rotation {
1623                transform = transform.with_rotation(*quat);
1624            }
1625            if let Some(Vpeol3dScale(scale)) = scale {
1626                transform = transform.with_scale(*scale);
1627            }
1628
1629            if let Ok(VpeolRepositionLevel(level_transform)) =
1630                levels_query.get(belongs_to_level.level)
1631            {
1632                transform = *level_transform * transform;
1633            }
1634
1635            cmd.insert((transform, GlobalTransform::from(transform)));
1636        },
1637    )
1638}