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 {
14//!     enable_multipass_for_primary_context: true,
15//! });
16//! app.add_plugins(YoleckPluginForEditor);
17//! // - Use `Vpeol3dPluginForGame` instead when setting up for game.
18//! // - Use topdown is for games that utilize the XZ plane. There is also
19//! //   `Vpeol3dPluginForEditor::sidescroller` for games that mainly need the XY plane.
20//! app.add_plugins(Vpeol3dPluginForEditor::topdown());
21//! ```
22//!
23//! Add the following components to the camera entity:
24//! * [`VpeolCameraState`] in order to select and drag entities.
25//! * [`Vpeol3dCameraControl`] in order to control the camera with the mouse. This one can be
26//!   skipped if there are other means to control the camera inside the editor, or if no camera
27//!   control inside the editor is desired.
28//!
29//! ```no_run
30//! # use bevy::prelude::*;
31//! # use bevy_yoleck::vpeol::prelude::*;
32//! # let commands: Commands = panic!();
33//! commands
34//!     .spawn(Camera3d::default())
35//!     .insert(VpeolCameraState::default())
36//!     // Use a variant of the camera controls that fit the choice of editor plugin.
37//!     .insert(Vpeol3dCameraControl::topdown());
38//! ```
39//!
40//! Entity selection by clicking on it is supported by just adding the plugin. To implement
41//! dragging, there are two options:
42//!
43//! 1. Add  the [`Vpeol3dPosition`] Yoleck component and use it as the source of position (there
44//!    are also [`Vpeol3dRotation`] and [`Vpeol3dScale`], but they don't currently get editing
45//!    support from vpeol_3d). To enable dragging across the third axis, add
46//!    [`Vpeol3dThirdAxisWithKnob`] as well.
47//!     ```no_run
48//!     # use bevy::prelude::*;
49//!     # use bevy_yoleck::prelude::*;
50//!     # use bevy_yoleck::vpeol::prelude::*;
51//!     # use serde::{Deserialize, Serialize};
52//!     # #[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
53//!     # struct Example;
54//!     # let mut app = App::new();
55//!     app.add_yoleck_entity_type({
56//!         YoleckEntityType::new("Example")
57//!             .with::<Vpeol3dPosition>() // vpeol_3d dragging
58//!             .with::<Example>() // entity's specific data and systems
59//!             // Optional:
60//!             .insert_on_init_during_editor(|| Vpeol3dThirdAxisWithKnob {
61//!                 knob_distance: 2.0,
62//!                 knob_scale: 0.5,
63//!             })
64//!     });
65//!     ```
66//! 2. Use data passing. vpeol_3d will pass a `Vec3` to the entity being dragged:
67//!     ```no_run
68//!     # use bevy::prelude::*;
69//!     # use bevy_yoleck::prelude::*;
70//!     # use serde::{Deserialize, Serialize};
71//!     # #[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
72//!     # struct Example {
73//!     #     position: Vec3,
74//!     # }
75//!     # let mut app = App::new();
76//!     fn edit_example(mut edit: YoleckEdit<(Entity, &mut Example)>, passed_data: Res<YoleckPassedData>) {
77//!         let Ok((entity, mut example)) = edit.single_mut() else { return };
78//!         if let Some(pos) = passed_data.get::<Vec3>(entity) {
79//!             example.position = *pos;
80//!         }
81//!     }
82//!
83//!     fn populate_example(
84//!         mut populate: YoleckPopulate<&Example>,
85//!         asset_server: Res<AssetServer>
86//!     ) {
87//!         populate.populate(|_ctx, mut cmd, example| {
88//!             cmd.insert(Transform::from_translation(example.position));
89//!             cmd.insert(SceneRoot(asset_server.load("scene.glb#Scene0")));
90//!         });
91//!     }
92//!     ```
93//!     When using this option, [`Vpeol3dThirdAxisWithKnob`] can still be used to add the third
94//!     axis knob.
95
96use std::any::TypeId;
97
98use crate::bevy_egui::egui;
99use crate::exclusive_systems::{
100    YoleckEntityCreationExclusiveSystems, YoleckExclusiveSystemDirective,
101};
102use crate::vpeol::{
103    handle_clickable_children_system, ray_intersection_with_mesh, VpeolBasePlugin,
104    VpeolCameraState, VpeolDragPlane, VpeolRepositionLevel, VpeolRootResolver, VpeolSystemSet,
105};
106use crate::{prelude::*, YoleckDirective, YoleckSchedule};
107use bevy::color::palettes::css;
108use bevy::input::mouse::MouseWheel;
109use bevy::math::DVec3;
110use bevy::platform::collections::HashMap;
111use bevy::prelude::*;
112use bevy::render::view::VisibleEntities;
113use bevy_egui::EguiContexts;
114use serde::{Deserialize, Serialize};
115
116/// Add the systems required for loading levels that use vpeol_3d components
117pub struct Vpeol3dPluginForGame;
118
119impl Plugin for Vpeol3dPluginForGame {
120    fn build(&self, app: &mut App) {
121        app.add_systems(
122            YoleckSchedule::OverrideCommonComponents,
123            vpeol_3d_populate_transform,
124        );
125        #[cfg(feature = "bevy_reflect")]
126        register_reflect_types(app);
127    }
128}
129
130#[cfg(feature = "bevy_reflect")]
131fn register_reflect_types(app: &mut App) {
132    app.register_type::<Vpeol3dPosition>();
133    app.register_type::<Vpeol3dRotation>();
134    app.register_type::<Vpeol3dScale>();
135    app.register_type::<Vpeol3dCameraControl>();
136}
137
138/// Add the systems required for 3D editing.
139///
140/// * 3D camera control (for cameras with [`Vpeol3dCameraControl`])
141/// * Entity selection.
142/// * Entity dragging.
143/// * Connecting nested entities.
144pub struct Vpeol3dPluginForEditor {
145    /// The plane to configure the global [`VpeolDragPlane`] resource with.
146    ///
147    /// Indiviual entities can override this with their own [`VpeolDragPlane`] component.
148    ///
149    /// It is a good idea to match this to [`Vpeol3dCameraControl::plane`].
150    pub drag_plane: InfinitePlane3d,
151}
152
153impl Vpeol3dPluginForEditor {
154    /// For sidescroller games - drag entities along the XY plane.
155    ///
156    /// Indiviual entities can override this with a [`VpeolDragPlane`] component.
157    ///
158    /// Adding [`Vpeol3dThirdAxisWithKnob`] can be used to allow Z axis manipulation.
159    ///
160    /// This combines well with [`Vpeol3dCameraControl::sidescroller`].
161    pub fn sidescroller() -> Self {
162        Self {
163            drag_plane: InfinitePlane3d { normal: Dir3::Z },
164        }
165    }
166
167    /// For games that are not sidescrollers - drag entities along the XZ plane.
168    ///
169    /// Indiviual entities can override this with a [`VpeolDragPlane`] component.
170    ///
171    /// Adding [`Vpeol3dThirdAxisWithKnob`] can be used to allow Y axis manipulation.
172    ///
173    /// This combines well with [`Vpeol3dCameraControl::topdown`].
174    pub fn topdown() -> Self {
175        Self {
176            drag_plane: InfinitePlane3d { normal: Dir3::Y },
177        }
178    }
179}
180
181impl Plugin for Vpeol3dPluginForEditor {
182    fn build(&self, app: &mut App) {
183        app.add_plugins(VpeolBasePlugin);
184        app.add_plugins(Vpeol3dPluginForGame);
185        app.insert_resource(VpeolDragPlane(self.drag_plane));
186
187        app.add_systems(
188            Update,
189            (update_camera_status_for_models,).in_set(VpeolSystemSet::UpdateCameraState),
190        );
191        app.add_systems(
192            PostUpdate, // to prevent camera shaking
193            (
194                camera_3d_pan,
195                camera_3d_move_along_plane_normal,
196                camera_3d_rotate,
197            )
198                .run_if(in_state(YoleckEditorState::EditorActive)),
199        );
200        app.add_systems(
201            Update,
202            (
203                ApplyDeferred,
204                handle_clickable_children_system::<With<Mesh3d>, ()>,
205                ApplyDeferred,
206            )
207                .chain()
208                .run_if(in_state(YoleckEditorState::EditorActive)),
209        );
210        app.add_yoleck_edit_system(vpeol_3d_edit_position);
211        app.world_mut()
212            .resource_mut::<YoleckEntityCreationExclusiveSystems>()
213            .on_entity_creation(|queue| queue.push_back(vpeol_3d_init_position));
214        app.add_yoleck_edit_system(vpeol_3d_edit_third_axis_with_knob);
215    }
216}
217
218fn update_camera_status_for_models(
219    mut cameras_query: Query<(&mut VpeolCameraState, &VisibleEntities)>,
220    entities_query: Query<(Entity, &GlobalTransform, &Mesh3d)>,
221    mesh_assets: Res<Assets<Mesh>>,
222    root_resolver: VpeolRootResolver,
223) {
224    for (mut camera_state, visible_entities) in cameras_query.iter_mut() {
225        let Some(cursor_ray) = camera_state.cursor_ray else {
226            continue;
227        };
228        for (entity, global_transform, mesh) in
229            entities_query.iter_many(visible_entities.iter(TypeId::of::<Mesh3d>()))
230        {
231            let Some(mesh) = mesh_assets.get(&mesh.0) else {
232                continue;
233            };
234
235            let inverse_transform = global_transform.compute_matrix().inverse();
236
237            // Note: the transform may change the ray's length, which Bevy no longer supports
238            // (since version 0.13), so we keep the ray length separately and apply it later to the
239            // distance.
240            let ray_origin = inverse_transform.transform_point3(cursor_ray.origin);
241            let ray_vector = inverse_transform.transform_vector3(*cursor_ray.direction);
242            let Ok((ray_direction, ray_length_factor)) = Dir3::new_and_length(ray_vector) else {
243                continue;
244            };
245
246            let ray_in_object_coords = Ray3d {
247                origin: ray_origin,
248                direction: ray_direction,
249            };
250
251            let Some(distance) = ray_intersection_with_mesh(ray_in_object_coords, mesh) else {
252                continue;
253            };
254
255            let distance = distance / ray_length_factor;
256
257            let Some(root_entity) = root_resolver.resolve_root(entity) else {
258                continue;
259            };
260            camera_state.consider(root_entity, -distance, || cursor_ray.get_point(distance));
261        }
262    }
263}
264
265/// Move and rotate a camera entity with the mouse while inisde the editor.
266#[derive(Component)]
267#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
268pub struct Vpeol3dCameraControl {
269    /// Panning is done by dragging a plane with this as its origin.
270    pub plane_origin: Vec3,
271    /// Panning is done by dragging along this plane.
272    pub plane: InfinitePlane3d,
273    /// Is `Some`, enable mouse rotation. The up direction of the camera will be the specific
274    /// direction.
275    ///
276    /// It is a good idea to match this to [`Vpeol3dPluginForEditor::drag_plane`].
277    pub allow_rotation_while_maintaining_up: Option<Dir3>,
278    /// How much to change the proximity to the plane when receiving scroll event in
279    /// `MouseScrollUnit::Line` units.
280    pub proximity_per_scroll_line: f32,
281    /// How much to change the proximity to the plane when receiving scroll event in
282    /// `MouseScrollUnit::Pixel` units.
283    pub proximity_per_scroll_pixel: f32,
284}
285
286impl Vpeol3dCameraControl {
287    /// Preset for sidescroller games, where the the game world is on the XY plane.
288    ///
289    /// With this preset, the camera rotation is disabled.
290    ///
291    /// This combines well with [`Vpeol3dPluginForEditor::sidescroller`].
292    pub fn sidescroller() -> Self {
293        Self {
294            plane_origin: Vec3::ZERO,
295            plane: InfinitePlane3d {
296                normal: Dir3::NEG_Z,
297            },
298            allow_rotation_while_maintaining_up: None,
299            proximity_per_scroll_line: 2.0,
300            proximity_per_scroll_pixel: 0.01,
301        }
302    }
303
304    /// Preset for games where the the game world is mainly on the XZ plane (though there can still
305    /// be verticality)
306    ///
307    /// This combines well with [`Vpeol3dPluginForEditor::topdown`].
308    pub fn topdown() -> Self {
309        Self {
310            plane_origin: Vec3::ZERO,
311            plane: InfinitePlane3d { normal: Dir3::Y },
312            allow_rotation_while_maintaining_up: Some(Dir3::Y),
313            proximity_per_scroll_line: 2.0,
314            proximity_per_scroll_pixel: 0.01,
315        }
316    }
317
318    fn ray_intersection(&self, ray: Ray3d) -> Option<Vec3> {
319        let distance = ray.intersect_plane(self.plane_origin, self.plane)?;
320        Some(ray.get_point(distance))
321    }
322}
323
324fn camera_3d_pan(
325    mut egui_context: EguiContexts,
326    mouse_buttons: Res<ButtonInput<MouseButton>>,
327    mut cameras_query: Query<(
328        Entity,
329        &mut Transform,
330        &VpeolCameraState,
331        &Vpeol3dCameraControl,
332    )>,
333    mut last_cursor_world_pos_by_camera: Local<HashMap<Entity, Vec3>>,
334) {
335    enum MouseButtonOp {
336        JustPressed,
337        BeingPressed,
338    }
339
340    let mouse_button_op = if mouse_buttons.just_pressed(MouseButton::Right) {
341        if egui_context.ctx_mut().is_pointer_over_area() {
342            return;
343        }
344        MouseButtonOp::JustPressed
345    } else if mouse_buttons.pressed(MouseButton::Right) {
346        MouseButtonOp::BeingPressed
347    } else {
348        last_cursor_world_pos_by_camera.clear();
349        return;
350    };
351
352    for (camera_entity, mut camera_transform, camera_state, camera_control) in
353        cameras_query.iter_mut()
354    {
355        let Some(cursor_ray) = camera_state.cursor_ray else {
356            continue;
357        };
358        match mouse_button_op {
359            MouseButtonOp::JustPressed => {
360                let Some(world_pos) = camera_control.ray_intersection(cursor_ray) else {
361                    continue;
362                };
363                last_cursor_world_pos_by_camera.insert(camera_entity, world_pos);
364            }
365            MouseButtonOp::BeingPressed => {
366                if let Some(prev_pos) = last_cursor_world_pos_by_camera.get_mut(&camera_entity) {
367                    let Some(world_pos) = camera_control.ray_intersection(cursor_ray) else {
368                        continue;
369                    };
370                    let movement = *prev_pos - world_pos;
371                    camera_transform.translation += movement;
372                }
373            }
374        }
375    }
376}
377
378fn camera_3d_move_along_plane_normal(
379    mut egui_context: EguiContexts,
380    mut cameras_query: Query<(&mut Transform, &Vpeol3dCameraControl)>,
381    mut wheel_events_reader: EventReader<MouseWheel>,
382) {
383    if egui_context.ctx_mut().is_pointer_over_area() {
384        return;
385    }
386
387    for (mut camera_transform, camera_control) in cameras_query.iter_mut() {
388        let zoom_amount: f32 = wheel_events_reader
389            .read()
390            .map(|wheel_event| match wheel_event.unit {
391                bevy::input::mouse::MouseScrollUnit::Line => {
392                    wheel_event.y * camera_control.proximity_per_scroll_line
393                }
394                bevy::input::mouse::MouseScrollUnit::Pixel => {
395                    wheel_event.y * camera_control.proximity_per_scroll_pixel
396                }
397            })
398            .sum();
399
400        if zoom_amount == 0.0 {
401            continue;
402        }
403
404        camera_transform.translation += zoom_amount * *camera_control.plane.normal;
405    }
406}
407
408fn camera_3d_rotate(
409    mut egui_context: EguiContexts,
410    mouse_buttons: Res<ButtonInput<MouseButton>>,
411    mut cameras_query: Query<(
412        Entity,
413        &mut Transform,
414        &VpeolCameraState,
415        &Vpeol3dCameraControl,
416    )>,
417    mut last_cursor_ray_by_camera: Local<HashMap<Entity, Ray3d>>,
418) {
419    enum MouseButtonOp {
420        JustPressed,
421        BeingPressed,
422    }
423
424    let mouse_button_op = if mouse_buttons.just_pressed(MouseButton::Middle) {
425        if egui_context.ctx_mut().is_pointer_over_area() {
426            return;
427        }
428        MouseButtonOp::JustPressed
429    } else if mouse_buttons.pressed(MouseButton::Middle) {
430        MouseButtonOp::BeingPressed
431    } else {
432        last_cursor_ray_by_camera.clear();
433        return;
434    };
435
436    for (camera_entity, mut camera_transform, camera_state, camera_control) in
437        cameras_query.iter_mut()
438    {
439        let Some(maintaining_up) = camera_control.allow_rotation_while_maintaining_up else {
440            continue;
441        };
442        let Some(cursor_ray) = camera_state.cursor_ray else {
443            continue;
444        };
445        match mouse_button_op {
446            MouseButtonOp::JustPressed => {
447                last_cursor_ray_by_camera.insert(camera_entity, cursor_ray);
448            }
449            MouseButtonOp::BeingPressed => {
450                if let Some(prev_ray) = last_cursor_ray_by_camera.get_mut(&camera_entity) {
451                    let rotation =
452                        Quat::from_rotation_arc(*cursor_ray.direction, *prev_ray.direction);
453                    camera_transform.rotate(rotation);
454                    let new_forward = camera_transform.forward();
455                    camera_transform.look_to(*new_forward, *maintaining_up);
456                }
457            }
458        }
459    }
460}
461
462/// A position component that's edited and populated by vpeol_3d.
463///
464/// Editing is done with egui, or by dragging the entity on a [`VpeolDragPlane`]  that passes
465/// through the entity. To support dragging perpendicular to that plane, use
466/// [`Vpeol3dThirdAxisWithKnob`].
467#[derive(Clone, PartialEq, Serialize, Deserialize, Component, Default, YoleckComponent)]
468#[serde(transparent)]
469#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
470pub struct Vpeol3dPosition(pub Vec3);
471
472/// Add a knob for dragging the entity perpendicular to the [`VpeolDragPlane`].
473///
474/// Dragging the knob will not actually change any component - it will only pass to the entity a
475/// `Vec3` that describes the drag. Since regular entity dragging is also implemented by passing a
476/// `Vec3`, just adding this component should be enough if there is already an edit system in place
477/// that reads that `Vec3` (such as the edit system for [`Vpeol3dPosition`])
478#[derive(Component)]
479pub struct Vpeol3dThirdAxisWithKnob {
480    /// The distance of the knob from the entity's origin.
481    pub knob_distance: f32,
482    /// A scale for the knob's model.
483    pub knob_scale: f32,
484}
485
486/// A rotation component that's populated (but not edited) by vpeol_3d.
487#[derive(Default, Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
488#[serde(transparent)]
489#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
490pub struct Vpeol3dRotation(pub Quat);
491
492/// A scale component that's populated (but not edited) by vpeol_3d.
493#[derive(Clone, PartialEq, Serialize, Deserialize, Component, YoleckComponent)]
494#[serde(transparent)]
495#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
496pub struct Vpeol3dScale(pub Vec3);
497
498impl Default for Vpeol3dScale {
499    fn default() -> Self {
500        Self(Vec3::ONE)
501    }
502}
503
504enum CommonDragPlane {
505    NotDecidedYet,
506    WithNormal(Vec3),
507    NoSharedPlane,
508}
509
510impl CommonDragPlane {
511    fn consider(&mut self, normal: Vec3) {
512        *self = match self {
513            CommonDragPlane::NotDecidedYet => CommonDragPlane::WithNormal(normal),
514            CommonDragPlane::WithNormal(current_normal) => {
515                if *current_normal == normal {
516                    CommonDragPlane::WithNormal(normal)
517                } else {
518                    CommonDragPlane::NoSharedPlane
519                }
520            }
521            CommonDragPlane::NoSharedPlane => CommonDragPlane::NoSharedPlane,
522        }
523    }
524
525    fn shared_normal(&self) -> Option<Vec3> {
526        if let CommonDragPlane::WithNormal(normal) = self {
527            Some(*normal)
528        } else {
529            None
530        }
531    }
532}
533
534fn vpeol_3d_edit_position(
535    mut ui: ResMut<YoleckUi>,
536    mut edit: YoleckEdit<(Entity, &mut Vpeol3dPosition, Option<&VpeolDragPlane>)>,
537    global_drag_plane: Res<VpeolDragPlane>,
538    passed_data: Res<YoleckPassedData>,
539) {
540    if edit.is_empty() || edit.has_nonmatching() {
541        return;
542    }
543    // Use double precision to prevent rounding errors when there are many entities.
544    let mut average = DVec3::ZERO;
545    let mut num_entities = 0;
546    let mut transition = Vec3::ZERO;
547
548    let mut common_drag_plane = CommonDragPlane::NotDecidedYet;
549
550    for (entity, position, drag_plane) in edit.iter_matching() {
551        let VpeolDragPlane(drag_plane) = drag_plane.unwrap_or(global_drag_plane.as_ref());
552        common_drag_plane.consider(*drag_plane.normal);
553
554        if let Some(pos) = passed_data.get::<Vec3>(entity) {
555            transition = *pos - position.0;
556        }
557        average += position.0.as_dvec3();
558        num_entities += 1;
559    }
560    average /= num_entities as f64;
561
562    if common_drag_plane.shared_normal().is_none() {
563        transition = Vec3::ZERO;
564        ui.label(
565            egui::RichText::new("Drag plane differs - cannot drag together")
566                .color(egui::Color32::RED),
567        );
568    }
569    ui.horizontal(|ui| {
570        let mut new_average = average;
571        ui.add(egui::DragValue::new(&mut new_average.x).prefix("X:"));
572        ui.add(egui::DragValue::new(&mut new_average.y).prefix("Y:"));
573        ui.add(egui::DragValue::new(&mut new_average.z).prefix("Z:"));
574        transition += (new_average - average).as_vec3();
575    });
576
577    if transition.is_finite() && transition != Vec3::ZERO {
578        for (_, mut position, _) in edit.iter_matching_mut() {
579            position.0 += transition;
580        }
581    }
582}
583
584fn vpeol_3d_init_position(
585    mut egui_context: EguiContexts,
586    ui: Res<YoleckUi>,
587    mut edit: YoleckEdit<(&mut Vpeol3dPosition, Option<&VpeolDragPlane>)>,
588    global_drag_plane: Res<VpeolDragPlane>,
589    cameras_query: Query<&VpeolCameraState>,
590    mouse_buttons: Res<ButtonInput<MouseButton>>,
591) -> YoleckExclusiveSystemDirective {
592    let Ok((mut position, drag_plane)) = edit.single_mut() else {
593        return YoleckExclusiveSystemDirective::Finished;
594    };
595
596    let Some(cursor_ray) = cameras_query
597        .iter()
598        .find_map(|camera_state| camera_state.cursor_ray)
599    else {
600        return YoleckExclusiveSystemDirective::Listening;
601    };
602
603    let VpeolDragPlane(drag_plane) = drag_plane.unwrap_or(global_drag_plane.as_ref());
604    if let Some(distance_to_plane) =
605        cursor_ray.intersect_plane(position.0, InfinitePlane3d::new(*drag_plane.normal))
606    {
607        position.0 = cursor_ray.get_point(distance_to_plane);
608    };
609
610    if egui_context.ctx_mut().is_pointer_over_area() || ui.ctx().is_pointer_over_area() {
611        return YoleckExclusiveSystemDirective::Listening;
612    }
613
614    if mouse_buttons.just_released(MouseButton::Left) {
615        return YoleckExclusiveSystemDirective::Finished;
616    }
617
618    YoleckExclusiveSystemDirective::Listening
619}
620
621fn vpeol_3d_edit_third_axis_with_knob(
622    mut edit: YoleckEdit<(
623        Entity,
624        &GlobalTransform,
625        &Vpeol3dThirdAxisWithKnob,
626        Option<&VpeolDragPlane>,
627    )>,
628    global_drag_plane: Res<VpeolDragPlane>,
629    mut knobs: YoleckKnobs,
630    mut mesh_assets: ResMut<Assets<Mesh>>,
631    mut material_assets: ResMut<Assets<StandardMaterial>>,
632    mut mesh_and_material: Local<Option<(Handle<Mesh>, Handle<StandardMaterial>)>>,
633    mut directives_writer: EventWriter<YoleckDirective>,
634) {
635    if edit.is_empty() || edit.has_nonmatching() {
636        return;
637    }
638
639    let (mesh, material) = mesh_and_material.get_or_insert_with(|| {
640        (
641            mesh_assets.add(Mesh::from(Cylinder {
642                radius: 0.5,
643                half_height: 0.5,
644            })),
645            material_assets.add(Color::from(css::ORANGE_RED)),
646        )
647    });
648
649    let mut common_drag_plane = CommonDragPlane::NotDecidedYet;
650    for (_, _, _, drag_plane) in edit.iter_matching() {
651        let VpeolDragPlane(drag_plane) = drag_plane.unwrap_or(global_drag_plane.as_ref());
652        common_drag_plane.consider(*drag_plane.normal);
653    }
654    let Some(drag_plane_normal) = common_drag_plane.shared_normal() else {
655        return;
656    };
657
658    for (entity, global_transform, third_axis_with_knob, _) in edit.iter_matching() {
659        let entity_position = global_transform.translation();
660
661        for (knob_name, drag_plane_normal) in [
662            ("vpeol-3d-third-axis-knob-positive", drag_plane_normal),
663            ("vpeol-3d-third-axis-knob-negative", -drag_plane_normal),
664        ] {
665            let mut knob = knobs.knob((entity, knob_name));
666            let knob_offset = third_axis_with_knob.knob_distance * drag_plane_normal;
667            let knob_transform = Transform {
668                translation: entity_position + knob_offset,
669                rotation: Quat::from_rotation_arc(Vec3::Y, drag_plane_normal),
670                scale: third_axis_with_knob.knob_scale * Vec3::ONE,
671            };
672            knob.cmd.insert(VpeolDragPlane(InfinitePlane3d {
673                normal: Dir3::new(drag_plane_normal.cross(Vec3::X)).unwrap_or(Dir3::Y),
674            }));
675            knob.cmd.insert((
676                Mesh3d(mesh.clone()),
677                MeshMaterial3d(material.clone()),
678                knob_transform,
679                GlobalTransform::from(knob_transform),
680            ));
681            if let Some(pos) = knob.get_passed_data::<Vec3>() {
682                let vector_from_entity = *pos - knob_offset - entity_position;
683                let along_drag_normal = vector_from_entity.dot(drag_plane_normal);
684                let vector_along_drag_normal = along_drag_normal * drag_plane_normal;
685                let position_along_drag_normal = entity_position + vector_along_drag_normal;
686                // NOTE: we don't need to send this to all the selected entities. This will be
687                // handled in the system that receives the passed data.
688                directives_writer.write(YoleckDirective::pass_to_entity(
689                    entity,
690                    position_along_drag_normal,
691                ));
692            }
693        }
694    }
695}
696
697fn vpeol_3d_populate_transform(
698    mut populate: YoleckPopulate<(
699        &Vpeol3dPosition,
700        Option<&Vpeol3dRotation>,
701        Option<&Vpeol3dScale>,
702        &YoleckBelongsToLevel,
703    )>,
704    levels_query: Query<&VpeolRepositionLevel>,
705) {
706    populate.populate(
707        |_ctx, mut cmd, (position, rotation, scale, belongs_to_level)| {
708            let mut transform = Transform::from_translation(position.0);
709            if let Some(Vpeol3dRotation(rotation)) = rotation {
710                transform = transform.with_rotation(*rotation);
711            }
712            if let Some(Vpeol3dScale(scale)) = scale {
713                transform = transform.with_scale(*scale);
714            }
715
716            if let Ok(VpeolRepositionLevel(level_transform)) =
717                levels_query.get(belongs_to_level.level)
718            {
719                transform = *level_transform * transform;
720            }
721
722            cmd.insert((transform, GlobalTransform::from(transform)));
723        },
724    )
725}