Skip to main content

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