1use 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
151pub 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
173pub struct Vpeol3dPluginForEditor {
180 pub drag_plane: InfinitePlane3d,
186}
187
188impl Vpeol3dPluginForEditor {
189 pub fn sidescroller() -> Self {
195 Self {
196 drag_plane: InfinitePlane3d { normal: Dir3::Z },
197 }
198 }
199
200 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 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
314pub struct YoleckCameraChoice {
319 pub name: String,
321 pub control: Vpeol3dCameraControl,
323 pub initial_transform: Option<(Vec3, Vec3, Vec3)>,
325}
326
327#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
431#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
432pub enum Vpeol3dCameraMode {
433 Fps,
435 Sidescroller,
437 Topdown,
439 Custom(u32),
441}
442
443#[derive(Component, Clone)]
445#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
446pub struct Vpeol3dCameraControl {
447 pub mode: Vpeol3dCameraMode,
451 pub plane: InfinitePlane3d,
453 pub allow_rotation_while_maintaining_up: Option<Dir3>,
458 pub proximity_per_scroll_line: f32,
461 pub proximity_per_scroll_pixel: f32,
464 pub wasd_movement_speed: f32,
466 pub mouse_sensitivity: f32,
468}
469
470impl Vpeol3dCameraControl {
471 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 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 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#[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#[derive(Component)]
1044pub struct Vpeol3dSnapToPlane {
1045 pub normal: Dir3,
1046 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#[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#[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 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}