1use bevy_app::{App, Plugin, PostUpdate};
20use bevy_camera::Camera;
21use bevy_color::Color;
22use bevy_ecs::{
23 component::Component,
24 entity::Entity,
25 query::With,
26 reflect::{ReflectComponent, ReflectResource},
27 resource::Resource,
28 schedule::{IntoScheduleConfigs, SystemSet},
29 system::{Local, Query, Res, ResMut, Single},
30};
31use bevy_input::{mouse::MouseButton, ButtonInput};
32use bevy_math::{Quat, Vec2, Vec3};
33use bevy_reflect::{std_traits::ReflectDefault, Reflect};
34use bevy_shape::Ray3d;
35use bevy_transform::components::{GlobalTransform, Transform};
36use bevy_transform::TransformSystems;
37use bevy_window::{CursorGrabMode, CursorOptions, PrimaryWindow, Window};
38
39pub const AXIS_LENGTH: f32 = 1.0;
41pub const AXIS_TIP_LENGTH: f32 = 0.2;
43pub const AXIS_START_OFFSET: f32 = 0.2;
45pub const ROTATE_RING_RADIUS: f32 = 1.0;
47pub const SCALE_CUBE_SIZE: f32 = 0.07;
49
50pub const COLOR_X: Color = Color::srgb(1.0, 0.0, 0.49);
52pub const COLOR_Y: Color = Color::srgb(0.0, 1.0, 0.49);
54pub const COLOR_Z: Color = Color::srgb(0.0, 0.49, 1.0);
56pub const COLOR_VIEW: Color = Color::WHITE;
58pub const INACTIVE_ALPHA: f32 = 0.5;
60
61const MIN_SCALE: f32 = 0.01;
62pub const AXIS_HIT_DISTANCE: f32 = 35.0;
64
65pub const SHAFT_RADIUS: f32 = 0.015;
67pub const SHAFT_LENGTH: f32 = 0.6;
69pub const CONE_RADIUS: f32 = 0.05;
71pub const CONE_HEIGHT: f32 = 0.2;
73pub const VIEW_CIRCLE_MINOR: f32 = 0.01;
75pub const VIEW_CIRCLE_MAJOR: f32 = 0.15;
77pub const VIEW_RING_MINOR: f32 = 0.01;
79pub const VIEW_RING_MAJOR: f32 = 1.15;
81
82#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
87#[component(storage = "SparseSet")]
88#[reflect(Component, Default)]
89pub struct TransformGizmoFocus;
90
91#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
97#[component(storage = "SparseSet")]
98#[reflect(Component, Default)]
99pub struct TransformGizmoCamera;
100
101#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
103pub enum TransformGizmoMode {
104 #[default]
106 Translate,
107 Rotate,
109 Scale,
111}
112
113#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
115pub enum TransformGizmoSpace {
116 #[default]
118 World,
119 Local,
121}
122
123#[derive(Clone, Copy, PartialEq, Eq, Debug, Reflect)]
125pub enum TransformGizmoAxis {
126 X,
128 Y,
130 Z,
132 View,
134}
135
136#[derive(Resource, Reflect)]
138#[reflect(Resource)]
139pub struct TransformGizmoSettings {
140 pub mode: TransformGizmoMode,
142 pub space: TransformGizmoSpace,
144 pub axis_length: f32,
146 pub rotate_ring_radius: f32,
148 pub axis_hit_distance: f32,
150 pub snap_translate: Option<f32>,
152 pub snap_rotate: Option<f32>,
154 pub snap_scale: Option<f32>,
156 pub confine_cursor: bool,
158 pub screen_scale_factor: f32,
160 pub scale_sensitivity: f32,
162}
163
164impl Default for TransformGizmoSettings {
165 fn default() -> Self {
166 Self {
167 mode: TransformGizmoMode::default(),
168 space: TransformGizmoSpace::default(),
169 axis_length: AXIS_LENGTH,
170 rotate_ring_radius: ROTATE_RING_RADIUS,
171 axis_hit_distance: AXIS_HIT_DISTANCE,
172 snap_translate: None,
173 snap_rotate: None,
174 snap_scale: None,
175 confine_cursor: true,
176 screen_scale_factor: 0.1,
177 scale_sensitivity: 1.0,
178 }
179 }
180}
181
182#[derive(Resource, Default, Reflect)]
184#[reflect(Resource, Default)]
185pub struct TransformGizmoState {
186 pub hovered_axis: Option<TransformGizmoAxis>,
188 pub active: bool,
190 pub axis: Option<TransformGizmoAxis>,
192 pub start_transform: Transform,
194 pub entity: Option<Entity>,
196 pub drag_start_world: Vec3,
198 pub gizmo_origin: Vec3,
200}
201
202#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
210pub struct TransformGizmoSystems;
211
212#[derive(Component, Debug, Default, Clone, Copy)]
214pub struct TransformGizmoRoot;
215
216#[derive(Component, Debug, Clone, Copy)]
218pub struct TransformGizmoMeshMarker {
219 pub axis: TransformGizmoAxis,
221 pub mode: TransformGizmoMode,
223}
224
225pub struct TransformGizmoPlugin;
231
232impl Plugin for TransformGizmoPlugin {
233 fn build(&self, app: &mut App) {
234 app.init_resource::<TransformGizmoSettings>()
235 .init_resource::<TransformGizmoState>()
236 .register_type::<TransformGizmoFocus>()
237 .register_type::<TransformGizmoCamera>()
238 .register_type::<TransformGizmoSettings>()
239 .register_type::<TransformGizmoState>()
240 .configure_sets(PostUpdate, TransformGizmoSystems)
241 .add_systems(
242 PostUpdate,
243 (
244 transform_gizmo_drag.before(TransformSystems::Propagate),
245 transform_gizmo_hover.after(TransformSystems::Propagate),
246 )
247 .in_set(TransformGizmoSystems),
248 );
249 }
250}
251
252#[macro_export]
257macro_rules! resolve_gizmo_camera {
258 ($marked:expr, $all:expr) => {{
259 let mut marked_iter = $marked.iter();
260 if let ::core::option::Option::Some(first) = marked_iter.next() {
261 if marked_iter.next().is_some() {
262 bevy_log::warn_once!(
263 "Multiple cameras have the TransformGizmoCamera component; \
264 using the first one found."
265 );
266 }
267 ::core::option::Option::Some(first)
268 } else {
269 let mut all_iter = $all.iter();
270 match (all_iter.next(), all_iter.next()) {
271 (::core::option::Option::Some(cam), ::core::option::Option::None) => {
272 ::core::option::Option::Some(cam)
273 }
274 (::core::option::Option::Some(_), ::core::option::Option::Some(_)) => {
275 bevy_log::warn_once!(
276 "Multiple cameras exist but none has the TransformGizmoCamera \
277 component. Add TransformGizmoCamera to the camera the gizmo \
278 should use."
279 );
280 ::core::option::Option::None
281 }
282 _ => ::core::option::Option::None,
283 }
284 }
285 }};
286}
287
288fn transform_gizmo_hover(
289 focus: Option<Single<&GlobalTransform, With<TransformGizmoFocus>>>,
290 marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,
291 all_cameras: Query<(&Camera, &GlobalTransform)>,
292 window: Single<&Window, With<PrimaryWindow>>,
293 settings: Res<TransformGizmoSettings>,
294 mut state: ResMut<TransformGizmoState>,
295) {
296 state.hovered_axis = None;
297
298 if state.active {
299 return;
300 }
301
302 let Some(global_tf) = focus else {
303 return;
304 };
305 let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {
306 return;
307 };
308 let Some(cursor_pos) = window.cursor_position() else {
309 return;
310 };
311
312 let gizmo_pos = global_tf.translation();
313 let space = effective_space(&settings);
314 let rotation = gizmo_rotation(*global_tf, space);
315
316 let scale = if settings.screen_scale_factor > 0.0 {
317 (cam_tf.translation() - gizmo_pos).length() * settings.screen_scale_factor
318 } else {
319 1.0
320 };
321
322 let axes = [
323 (TransformGizmoAxis::X, rotation * Vec3::X),
324 (TransformGizmoAxis::Y, rotation * Vec3::Y),
325 (TransformGizmoAxis::Z, rotation * Vec3::Z),
326 ];
327
328 let mut best_axis = None;
329 let mut best_dist = f32::MAX;
330 let threshold = settings.axis_hit_distance;
331
332 for (axis, dir) in &axes {
333 let dist = match settings.mode {
334 TransformGizmoMode::Translate | TransformGizmoMode::Scale => {
335 let start = gizmo_pos + *dir * (AXIS_START_OFFSET * scale);
336 let endpoint = gizmo_pos + *dir * (settings.axis_length * scale);
337 let Some(start_screen) = camera.world_to_viewport(cam_tf, start).ok() else {
338 continue;
339 };
340 let Some(end_screen) = camera.world_to_viewport(cam_tf, endpoint).ok() else {
341 continue;
342 };
343 point_to_segment_dist(cursor_pos, start_screen, end_screen)
344 }
345 TransformGizmoMode::Rotate => point_to_ring_screen_dist(
346 cursor_pos,
347 camera,
348 cam_tf,
349 gizmo_pos,
350 *dir,
351 settings.rotate_ring_radius * scale,
352 ),
353 };
354 if dist < threshold && dist < best_dist {
355 best_dist = dist;
356 best_axis = Some(*axis);
357 }
358 }
359
360 let view_dist = match settings.mode {
362 TransformGizmoMode::Translate => {
363 if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {
365 let screen_radius = VIEW_CIRCLE_MAJOR * scale;
366 let edge_world = gizmo_pos + cam_tf.right() * screen_radius;
368 if let Ok(edge_screen) = camera.world_to_viewport(cam_tf, edge_world) {
369 let r = (edge_screen - center_screen).length();
370 let d = (cursor_pos - center_screen).length();
371 (d - r).abs()
373 } else {
374 f32::MAX
375 }
376 } else {
377 f32::MAX
378 }
379 }
380 TransformGizmoMode::Rotate => {
381 let cam_forward = cam_tf.forward().as_vec3();
383 point_to_ring_screen_dist(
384 cursor_pos,
385 camera,
386 cam_tf,
387 gizmo_pos,
388 cam_forward,
389 VIEW_RING_MAJOR * scale,
390 )
391 }
392 TransformGizmoMode::Scale => {
393 if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {
394 (cursor_pos - center_screen).length()
395 } else {
396 f32::MAX
397 }
398 }
399 };
400
401 if view_dist < threshold && view_dist < best_dist {
402 best_axis = Some(TransformGizmoAxis::View);
403 }
404
405 state.hovered_axis = best_axis;
406}
407
408fn transform_gizmo_drag(
409 mut focus_query: Query<(Entity, &GlobalTransform, &mut Transform), With<TransformGizmoFocus>>,
410 marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,
411 all_cameras: Query<(&Camera, &GlobalTransform)>,
412 primary_window: Single<(&Window, &mut CursorOptions), With<PrimaryWindow>>,
413 mouse: Res<ButtonInput<MouseButton>>,
414 settings: Res<TransformGizmoSettings>,
415 mut state: ResMut<TransformGizmoState>,
416 mut saved_grab_mode: Local<CursorGrabMode>,
417) {
418 let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {
419 return;
420 };
421 let (window, mut cursor_opts) = primary_window.into_inner();
422 let Some(cursor_pos) = window.cursor_position() else {
423 return;
424 };
425
426 if mouse.just_pressed(MouseButton::Left) && !state.active {
428 if let Some(axis) = state.hovered_axis
429 && let Some((entity, global_tf, transform)) = focus_query.iter().next()
430 {
431 let space = effective_space(&settings);
432 let rotation = gizmo_rotation(global_tf, space);
433 let axis_dir = axis_direction(axis, rotation, cam_tf);
434 let gizmo_pos = global_tf.translation();
435
436 let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {
438 return;
439 };
440
441 let drag_start_world = match settings.mode {
442 TransformGizmoMode::Translate | TransformGizmoMode::Scale => {
443 if axis == TransformGizmoAxis::View {
444 let plane_normal = cam_tf.forward().as_vec3();
446 let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)
447 else {
448 return;
449 };
450 intersection
451 } else {
452 let plane_normal = translation_plane_normal(ray, axis_dir);
453 let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)
454 else {
455 return;
456 };
457 let cursor_vec = intersection - gizmo_pos;
458 cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos
459 }
460 }
461 TransformGizmoMode::Rotate => {
462 let rot_axis = if axis == TransformGizmoAxis::View {
463 cam_tf.forward().as_vec3()
464 } else {
465 axis_dir.normalize()
466 };
467 let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_pos) else {
468 return;
469 };
470 (intersection - gizmo_pos).normalize()
471 }
472 };
473
474 state.active = true;
475 state.axis = Some(axis);
476 state.start_transform = *transform;
477 state.entity = Some(entity);
478 state.drag_start_world = drag_start_world;
479 state.gizmo_origin = gizmo_pos;
480
481 if settings.confine_cursor {
482 *saved_grab_mode = cursor_opts.grab_mode;
483 cursor_opts.grab_mode = CursorGrabMode::Confined;
484 }
485 }
486 return;
487 }
488
489 if state.active && mouse.pressed(MouseButton::Left) {
491 let Some(drag_entity) = state.entity else {
492 return;
493 };
494 let Some(axis) = state.axis else {
495 return;
496 };
497 let Ok((_, global_tf, mut transform)) = focus_query.get_mut(drag_entity) else {
498 return;
499 };
500
501 let space = effective_space(&settings);
502 let rotation = gizmo_rotation(global_tf, space);
503 let axis_dir = axis_direction(axis, rotation, cam_tf);
504 let gizmo_origin = state.gizmo_origin;
505
506 let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {
507 return;
508 };
509
510 match settings.mode {
511 TransformGizmoMode::Translate => {
512 if axis == TransformGizmoAxis::View {
513 let plane_normal = cam_tf.forward().as_vec3();
515 let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)
516 else {
517 return;
518 };
519 let delta = intersection - state.drag_start_world;
520 let new_pos = state.start_transform.translation + delta;
521 transform.translation = match settings.snap_translate {
522 Some(inc) => Vec3::new(
523 snap_value(new_pos.x, inc),
524 snap_value(new_pos.y, inc),
525 snap_value(new_pos.z, inc),
526 ),
527 None => new_pos,
528 };
529 } else {
530 let plane_normal = translation_plane_normal(ray, axis_dir);
531 let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)
532 else {
533 return;
534 };
535 let cursor_vec = intersection - gizmo_origin;
536 let axis_norm = axis_dir.normalize();
537 let new_projected = cursor_vec.dot(axis_norm) * axis_norm + gizmo_origin;
538 let delta = new_projected - state.drag_start_world;
539
540 transform.translation = match settings.snap_translate {
541 Some(inc) => {
542 state.start_transform.translation
543 + axis_norm * snap_value(delta.dot(axis_norm), inc)
544 }
545 None => state.start_transform.translation + delta,
546 };
547 }
548 }
549 TransformGizmoMode::Rotate => {
550 let rot_axis = if axis == TransformGizmoAxis::View {
551 cam_tf.forward().as_vec3()
552 } else {
553 axis_dir.normalize()
554 };
555 let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_origin) else {
556 return;
557 };
558 let cursor_vector = (intersection - gizmo_origin).normalize();
559 let drag_start = state.drag_start_world; let dot = drag_start.dot(cursor_vector);
562 let det = rot_axis.dot(drag_start.cross(cursor_vector));
563 let raw_angle = bevy_math::ops::atan2(det, dot);
564 let angle = match settings.snap_rotate {
565 Some(inc) => snap_value(raw_angle, inc),
566 None => raw_angle,
567 };
568 let rotation_delta = Quat::from_axis_angle(rot_axis, angle);
569 transform.rotation = rotation_delta * state.start_transform.rotation;
570 }
571 TransformGizmoMode::Scale => {
572 let (plane_normal, projection_dir) = if axis == TransformGizmoAxis::View {
573 let start_vec = state.drag_start_world - gizmo_origin;
574 let len = start_vec.length();
575 if len <= f32::EPSILON {
576 return;
577 }
578 (cam_tf.forward().as_vec3(), start_vec / len)
579 } else {
580 (
581 translation_plane_normal(ray, axis_dir),
582 axis_dir.normalize(),
583 )
584 };
585 let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin) else {
586 return;
587 };
588 let cursor_projected = (intersection - gizmo_origin).dot(projection_dir);
589 let start_projected = (state.drag_start_world - gizmo_origin).dot(projection_dir);
590
591 let scale_factor = match axis {
592 TransformGizmoAxis::X | TransformGizmoAxis::Y | TransformGizmoAxis::Z => {
593 if start_projected.abs() > f32::EPSILON {
594 let ratio = cursor_projected / start_projected;
595 1.0 + (ratio - 1.0) * settings.scale_sensitivity
596 } else {
597 1.0
598 }
599 }
600 TransformGizmoAxis::View => {
601 let delta = cursor_projected - start_projected;
602 bevy_math::ops::exp(delta * settings.scale_sensitivity)
603 }
604 };
605
606 let mut new_scale = state.start_transform.scale;
607 match axis {
608 TransformGizmoAxis::X => {
609 new_scale.x = (new_scale.x * scale_factor).max(MIN_SCALE);
610 }
611 TransformGizmoAxis::Y => {
612 new_scale.y = (new_scale.y * scale_factor).max(MIN_SCALE);
613 }
614 TransformGizmoAxis::Z => {
615 new_scale.z = (new_scale.z * scale_factor).max(MIN_SCALE);
616 }
617 TransformGizmoAxis::View => {
618 new_scale *= scale_factor;
620 new_scale = new_scale.max(Vec3::splat(MIN_SCALE));
621 }
622 }
623 transform.scale = match settings.snap_scale {
624 Some(inc) => {
625 let mut snapped = state.start_transform.scale;
626 match axis {
627 TransformGizmoAxis::X => {
628 snapped.x = snap_value(new_scale.x, inc).max(inc);
629 }
630 TransformGizmoAxis::Y => {
631 snapped.y = snap_value(new_scale.y, inc).max(inc);
632 }
633 TransformGizmoAxis::Z => {
634 snapped.z = snap_value(new_scale.z, inc).max(inc);
635 }
636 TransformGizmoAxis::View => {
637 snapped = Vec3::splat(snap_value(new_scale.x, inc));
638 snapped = snapped.max(Vec3::splat(inc));
639 }
640 }
641 snapped
642 }
643 None => new_scale,
644 };
645 }
646 }
647 return;
648 }
649
650 if state.active && !mouse.pressed(MouseButton::Left) {
652 state.active = false;
653 state.axis = None;
654 state.entity = None;
655 if settings.confine_cursor {
656 cursor_opts.grab_mode = *saved_grab_mode;
657 }
658 }
659}
660
661pub fn axis_direction(axis: TransformGizmoAxis, rotation: Quat, cam_tf: &GlobalTransform) -> Vec3 {
663 match axis {
664 TransformGizmoAxis::X => rotation * Vec3::X,
665 TransformGizmoAxis::Y => rotation * Vec3::Y,
666 TransformGizmoAxis::Z => rotation * Vec3::Z,
667 TransformGizmoAxis::View => cam_tf.forward().as_vec3(),
668 }
669}
670
671pub fn translation_plane_normal(ray: Ray3d, axis: Vec3) -> Vec3 {
676 let vertical = Vec3::from(ray.direction).cross(axis);
677 if vertical.length_squared() < f32::EPSILON {
678 return axis.any_orthonormal_vector();
680 }
681 axis.cross(vertical.normalize()).normalize()
682}
683
684pub fn intersect_plane(ray: Ray3d, plane_normal: Vec3, plane_origin: Vec3) -> Option<Vec3> {
686 let denominator = Vec3::from(ray.direction).dot(plane_normal);
687 if denominator.abs() > f32::EPSILON {
688 let point_to_point = plane_origin - ray.origin;
689 let intersect_dist = plane_normal.dot(point_to_point) / denominator;
690 Some(Vec3::from(ray.direction) * intersect_dist + ray.origin)
691 } else {
692 None
693 }
694}
695
696pub fn point_to_segment_dist(point: Vec2, a: Vec2, b: Vec2) -> f32 {
698 let ab = b - a;
699 let ap = point - a;
700 let t = (ap.dot(ab) / ab.length_squared()).clamp(0.0, 1.0);
701 let closest = a + ab * t;
702 (point - closest).length()
703}
704
705pub fn point_to_ring_screen_dist(
707 cursor: Vec2,
708 camera: &Camera,
709 cam_tf: &GlobalTransform,
710 center: Vec3,
711 normal: Vec3,
712 radius: f32,
713) -> f32 {
714 if let Ok(center_screen) = camera.world_to_viewport(cam_tf, center)
716 && let Ok(edge_screen) = camera.world_to_viewport(cam_tf, center + cam_tf.right() * radius)
717 {
718 let screen_radius = (edge_screen - center_screen).length();
719 let cursor_dist = (cursor - center_screen).length();
720 if (cursor_dist - screen_radius).abs() > screen_radius * 0.5 {
721 return f32::MAX;
722 }
723 }
724
725 const RING_SAMPLES: usize = 64;
726 let rot = Quat::from_rotation_arc(Vec3::Z, normal);
727 let mut min_dist = f32::MAX;
728 let mut prev_screen = None;
729
730 for i in 0..=RING_SAMPLES {
731 let angle = (i % RING_SAMPLES) as f32 * core::f32::consts::TAU / RING_SAMPLES as f32;
732 let local = Vec3::new(
733 bevy_math::ops::cos(angle) * radius,
734 bevy_math::ops::sin(angle) * radius,
735 0.0,
736 );
737 let world = center + rot * local;
738 let Some(screen) = camera.world_to_viewport(cam_tf, world).ok() else {
739 prev_screen = None;
740 continue;
741 };
742 if let Some(prev) = prev_screen {
743 let dist = point_to_segment_dist(cursor, prev, screen);
744 if dist < min_dist {
745 min_dist = dist;
746 }
747 }
748 prev_screen = Some(screen);
749 }
750
751 min_dist
752}
753
754pub fn effective_space(settings: &TransformGizmoSettings) -> &TransformGizmoSpace {
756 if settings.mode == TransformGizmoMode::Scale {
757 &TransformGizmoSpace::Local
758 } else {
759 &settings.space
760 }
761}
762
763pub fn gizmo_rotation(global_tf: &GlobalTransform, space: &TransformGizmoSpace) -> Quat {
765 match space {
766 TransformGizmoSpace::World => Quat::IDENTITY,
767 TransformGizmoSpace::Local => {
768 let (_, rotation, _) = global_tf.to_scale_rotation_translation();
769 rotation
770 }
771 }
772}
773
774fn snap_value(value: f32, increment: f32) -> f32 {
775 (value / increment).round() * increment
776}