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 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
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 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 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
315pub struct YoleckCameraChoice {
320 pub name: String,
322 pub control: Vpeol3dCameraControl,
324 pub initial_transform: Option<(Vec3, Vec3, Vec3)>,
326}
327
328#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
432#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
433pub enum Vpeol3dCameraMode {
434 Fps,
436 Sidescroller,
438 Topdown,
440 Custom(u32),
442}
443
444#[derive(Component, Clone)]
446#[cfg_attr(feature = "bevy_reflect", derive(bevy::reflect::Reflect))]
447pub struct Vpeol3dCameraControl {
448 pub mode: Vpeol3dCameraMode,
452 pub plane: InfinitePlane3d,
454 pub allow_rotation_while_maintaining_up: Option<Dir3>,
459 pub proximity_per_scroll_line: f32,
462 pub proximity_per_scroll_pixel: f32,
465 pub wasd_movement_speed: f32,
467 pub mouse_sensitivity: f32,
469}
470
471impl Vpeol3dCameraControl {
472 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 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 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#[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#[derive(Component)]
1045pub struct Vpeol3dSnapToPlane {
1046 pub normal: Dir3,
1047 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#[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#[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 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}