Skip to main content

bevy_gizmos/
transform_gizmo.rs

1//! Interactive transform gizmo for translating, rotating, and scaling entities.
2//!
3//! This module provides an opt-in transform gizmo that renders visual handles on a
4//! focused entity, allowing the user to click-and-drag to translate, rotate, or scale
5//! it. The plugin does **not** handle keyboard input -- users set
6//! [`TransformGizmoSettings::mode`] however they like (keyboard shortcuts, UI buttons,
7//! gamepad, etc.).
8//!
9//! # Quick start
10//!
11//! 1. Add [`TransformGizmoPlugin`] to your app.
12//! 2. Mark the camera with [`TransformGizmoCamera`].
13//! 3. Tag the entity you want to manipulate with [`TransformGizmoFocus`].
14//!
15//! If there is exactly one camera in the world, the [`TransformGizmoCamera`] marker
16//! is optional -- the gizmo will use that camera automatically. When multiple cameras
17//! exist, the marker is required so the gizmo knows which one to use.
18
19use 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, Ray3d, Vec2, Vec3};
33use bevy_reflect::{std_traits::ReflectDefault, Reflect};
34use bevy_transform::components::{GlobalTransform, Transform};
35use bevy_transform::TransformSystems;
36use bevy_window::{CursorGrabMode, CursorOptions, PrimaryWindow, Window};
37
38/// Default length of each axis handle.
39pub const AXIS_LENGTH: f32 = 1.0;
40/// Length of the arrow tip on translate handles.
41pub const AXIS_TIP_LENGTH: f32 = 0.2;
42/// Gap between the gizmo center and the start of each axis handle.
43pub const AXIS_START_OFFSET: f32 = 0.2;
44/// Default radius of the rotation rings.
45pub const ROTATE_RING_RADIUS: f32 = 1.0;
46/// Half-size of the scale cube tip.
47pub const SCALE_CUBE_SIZE: f32 = 0.07;
48
49/// Color for the X axis (magenta-pink).
50pub const COLOR_X: Color = Color::srgb(1.0, 0.0, 0.49);
51/// Color for the Y axis (green).
52pub const COLOR_Y: Color = Color::srgb(0.0, 1.0, 0.49);
53/// Color for the Z axis (blue).
54pub const COLOR_Z: Color = Color::srgb(0.0, 0.49, 1.0);
55/// Color for the view-plane handle (white).
56pub const COLOR_VIEW: Color = Color::WHITE;
57/// Alpha value used for inactive (non-hovered) axes during a drag.
58pub const INACTIVE_ALPHA: f32 = 0.5;
59
60const MIN_SCALE: f32 = 0.01;
61/// Default screen-space pixel distance threshold for hover detection.
62pub const AXIS_HIT_DISTANCE: f32 = 35.0;
63
64/// Radius of the cylinder mesh used for axis shafts.
65pub const SHAFT_RADIUS: f32 = 0.015;
66/// Height of the cylinder mesh used for axis shafts.
67pub const SHAFT_LENGTH: f32 = 0.6;
68/// Radius of the cone mesh used for translate arrow tips.
69pub const CONE_RADIUS: f32 = 0.05;
70/// Height of the cone mesh used for translate arrow tips.
71pub const CONE_HEIGHT: f32 = 0.2;
72/// Minor (tube) radius of the view-plane circle torus.
73pub const VIEW_CIRCLE_MINOR: f32 = 0.01;
74/// Major (ring) radius of the view-plane circle torus.
75pub const VIEW_CIRCLE_MAJOR: f32 = 0.15;
76/// Minor (tube) radius of the view-axis rotation ring torus.
77pub const VIEW_RING_MINOR: f32 = 0.01;
78/// Major (ring) radius of the view-axis rotation ring torus.
79pub const VIEW_RING_MAJOR: f32 = 1.15;
80
81/// Component that marks the entity the transform gizmo operates on.
82///
83/// Only one entity should carry this at a time. If multiple entities have it,
84/// the gizmo picks the first one returned by the query.
85#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
86#[component(storage = "SparseSet")]
87#[reflect(Component, Default)]
88pub struct TransformGizmoFocus;
89
90/// Marker component for the camera the transform gizmo should use.
91///
92/// When exactly one camera exists, this marker is optional. When multiple cameras
93/// exist, add this to the camera the gizmo should project through. If multiple
94/// cameras carry this marker, the first one found is used and a warning is logged.
95#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
96#[component(storage = "SparseSet")]
97#[reflect(Component, Default)]
98pub struct TransformGizmoCamera;
99
100/// Which manipulation mode the gizmo is in.
101#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
102pub enum TransformGizmoMode {
103    /// Move the entity along an axis.
104    #[default]
105    Translate,
106    /// Rotate the entity around an axis.
107    Rotate,
108    /// Scale the entity along an axis.
109    Scale,
110}
111
112/// Whether the gizmo transforms the object using world or local space axes.
113#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
114pub enum TransformGizmoSpace {
115    /// Axes are aligned to the world.
116    #[default]
117    World,
118    /// Axes follow the entity's local rotation.
119    Local,
120}
121
122/// Which axis the user is interacting with.
123#[derive(Clone, Copy, PartialEq, Eq, Debug, Reflect)]
124pub enum TransformGizmoAxis {
125    /// The X axis (red).
126    X,
127    /// The Y axis (green).
128    Y,
129    /// The Z axis (blue).
130    Z,
131    /// The view-plane / view-axis (white).
132    View,
133}
134
135/// Configuration and preferences for the transform gizmo.
136#[derive(Resource, Reflect)]
137#[reflect(Resource)]
138pub struct TransformGizmoSettings {
139    /// Which manipulation mode the gizmo is in.
140    pub mode: TransformGizmoMode,
141    /// Whether the gizmo transforms the object using world or local space axes.
142    pub space: TransformGizmoSpace,
143    /// Length of the axis handles.
144    pub axis_length: f32,
145    /// Radius of the rotation rings.
146    pub rotate_ring_radius: f32,
147    /// Screen-space pixel distance for hover detection.
148    pub axis_hit_distance: f32,
149    /// If set, translation snaps to this increment.
150    pub snap_translate: Option<f32>,
151    /// If set, rotation snaps to this increment (radians).
152    pub snap_rotate: Option<f32>,
153    /// If set, scale snaps to this increment.
154    pub snap_scale: Option<f32>,
155    /// Whether to confine the cursor during drag.
156    pub confine_cursor: bool,
157    /// Screen-space scale factor. Set to 0.0 to disable constant-size behavior.
158    pub screen_scale_factor: f32,
159}
160
161impl Default for TransformGizmoSettings {
162    fn default() -> Self {
163        Self {
164            mode: TransformGizmoMode::default(),
165            space: TransformGizmoSpace::default(),
166            axis_length: AXIS_LENGTH,
167            rotate_ring_radius: ROTATE_RING_RADIUS,
168            axis_hit_distance: AXIS_HIT_DISTANCE,
169            snap_translate: None,
170            snap_rotate: None,
171            snap_scale: None,
172            confine_cursor: true,
173            screen_scale_factor: 0.1,
174        }
175    }
176}
177
178/// Runtime state of the transform gizmo (drag and hover).
179#[derive(Resource, Default, Reflect)]
180#[reflect(Resource, Default)]
181pub struct TransformGizmoState {
182    /// The axis under the cursor, if any.
183    pub hovered_axis: Option<TransformGizmoAxis>,
184    /// `true` while the user is actively dragging.
185    pub active: bool,
186    /// The axis being dragged, if any.
187    pub axis: Option<TransformGizmoAxis>,
188    /// The transform snapshot taken when the drag started.
189    pub start_transform: Transform,
190    /// The entity being dragged, if any.
191    pub entity: Option<Entity>,
192    /// World-space point (or normalized direction for rotation) where the drag started.
193    pub drag_start_world: Vec3,
194    /// World-space gizmo origin at drag start.
195    pub gizmo_origin: Vec3,
196}
197
198/// System set for the transform gizmo. All transform gizmo systems run in [`PostUpdate`]
199/// within this set.
200///
201/// Add a run condition to control when the gizmo is active:
202/// ```ignore
203/// app.configure_sets(Update, TransformGizmoSystems.run_if(in_state(AppState::Editor)));
204/// ```
205#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
206pub struct TransformGizmoSystems;
207
208/// Marker component for the root entity of the gizmo mesh hierarchy.
209#[derive(Component, Debug, Default, Clone, Copy)]
210pub struct TransformGizmoRoot;
211
212/// Marker component for individual gizmo mesh parts.
213#[derive(Component, Debug, Clone, Copy)]
214pub struct TransformGizmoMeshMarker {
215    /// Which axis this mesh part represents.
216    pub axis: TransformGizmoAxis,
217    /// Which mode this mesh part is used in.
218    pub mode: TransformGizmoMode,
219}
220
221/// Opt-in plugin that adds the interactive transform gizmo.
222///
223/// This plugin registers the interaction logic (hover detection, drag handling,
224/// state management). Pair it with the render plugin in `bevy_gizmos_render`
225/// for mesh-based visualization.
226pub struct TransformGizmoPlugin;
227
228impl Plugin for TransformGizmoPlugin {
229    fn build(&self, app: &mut App) {
230        app.init_resource::<TransformGizmoSettings>()
231            .init_resource::<TransformGizmoState>()
232            .register_type::<TransformGizmoFocus>()
233            .register_type::<TransformGizmoCamera>()
234            .register_type::<TransformGizmoSettings>()
235            .register_type::<TransformGizmoState>()
236            .configure_sets(PostUpdate, TransformGizmoSystems)
237            .add_systems(
238                PostUpdate,
239                (
240                    transform_gizmo_drag.before(TransformSystems::Propagate),
241                    transform_gizmo_hover.after(TransformSystems::Propagate),
242                )
243                    .in_set(TransformGizmoSystems),
244            );
245    }
246}
247
248/// Resolves which camera the gizmo should use.
249///
250/// Prefers cameras marked with [`TransformGizmoCamera`]. Falls back to the sole
251/// camera in the world when no marker is present, and warns when ambiguous.
252#[macro_export]
253macro_rules! resolve_gizmo_camera {
254    ($marked:expr, $all:expr) => {{
255        let mut marked_iter = $marked.iter();
256        if let Some(first) = marked_iter.next() {
257            if marked_iter.next().is_some() {
258                bevy_log::warn_once!(
259                    "Multiple cameras have the TransformGizmoCamera component; \
260                     using the first one found."
261                );
262            }
263            Some(first)
264        } else {
265            let mut all_iter = $all.iter();
266            match (all_iter.next(), all_iter.next()) {
267                (Some(cam), None) => Some(cam),
268                (Some(_), Some(_)) => {
269                    bevy_log::warn_once!(
270                        "Multiple cameras exist but none has the TransformGizmoCamera \
271                         component. Add TransformGizmoCamera to the camera the gizmo \
272                         should use."
273                    );
274                    None
275                }
276                _ => None,
277            }
278        }
279    }};
280}
281
282fn transform_gizmo_hover(
283    focus: Option<Single<&GlobalTransform, With<TransformGizmoFocus>>>,
284    marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,
285    all_cameras: Query<(&Camera, &GlobalTransform)>,
286    window: Single<&Window, With<PrimaryWindow>>,
287    settings: Res<TransformGizmoSettings>,
288    mut state: ResMut<TransformGizmoState>,
289) {
290    state.hovered_axis = None;
291
292    if state.active {
293        return;
294    }
295
296    let Some(global_tf) = focus else {
297        return;
298    };
299    let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {
300        return;
301    };
302    let Some(cursor_pos) = window.cursor_position() else {
303        return;
304    };
305
306    let gizmo_pos = global_tf.translation();
307    let space = effective_space(&settings);
308    let rotation = gizmo_rotation(*global_tf, space);
309
310    let scale = if settings.screen_scale_factor > 0.0 {
311        (cam_tf.translation() - gizmo_pos).length() * settings.screen_scale_factor
312    } else {
313        1.0
314    };
315
316    let axes = [
317        (TransformGizmoAxis::X, rotation * Vec3::X),
318        (TransformGizmoAxis::Y, rotation * Vec3::Y),
319        (TransformGizmoAxis::Z, rotation * Vec3::Z),
320    ];
321
322    let mut best_axis = None;
323    let mut best_dist = f32::MAX;
324    let threshold = settings.axis_hit_distance;
325
326    for (axis, dir) in &axes {
327        let dist = match settings.mode {
328            TransformGizmoMode::Translate | TransformGizmoMode::Scale => {
329                let start = gizmo_pos + *dir * (AXIS_START_OFFSET * scale);
330                let endpoint = gizmo_pos + *dir * (settings.axis_length * scale);
331                let Some(start_screen) = camera.world_to_viewport(cam_tf, start).ok() else {
332                    continue;
333                };
334                let Some(end_screen) = camera.world_to_viewport(cam_tf, endpoint).ok() else {
335                    continue;
336                };
337                point_to_segment_dist(cursor_pos, start_screen, end_screen)
338            }
339            TransformGizmoMode::Rotate => point_to_ring_screen_dist(
340                cursor_pos,
341                camera,
342                cam_tf,
343                gizmo_pos,
344                *dir,
345                settings.rotate_ring_radius * scale,
346            ),
347        };
348        if dist < threshold && dist < best_dist {
349            best_dist = dist;
350            best_axis = Some(*axis);
351        }
352    }
353
354    // View handle hover detection
355    let view_dist = match settings.mode {
356        TransformGizmoMode::Translate => {
357            // Check if cursor is within the view-circle radius in screen space
358            if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {
359                let screen_radius = VIEW_CIRCLE_MAJOR * scale;
360                // Approximate screen-space radius: project a point on the circle edge
361                let edge_world = gizmo_pos + cam_tf.right() * screen_radius;
362                if let Ok(edge_screen) = camera.world_to_viewport(cam_tf, edge_world) {
363                    let r = (edge_screen - center_screen).length();
364                    let d = (cursor_pos - center_screen).length();
365                    // Hit if within the torus ring area
366                    (d - r).abs()
367                } else {
368                    f32::MAX
369                }
370            } else {
371                f32::MAX
372            }
373        }
374        TransformGizmoMode::Rotate => {
375            // View ring: check distance to a screen-space circle
376            let cam_forward = cam_tf.forward().as_vec3();
377            point_to_ring_screen_dist(
378                cursor_pos,
379                camera,
380                cam_tf,
381                gizmo_pos,
382                cam_forward,
383                VIEW_RING_MAJOR * scale,
384            )
385        }
386        TransformGizmoMode::Scale => f32::MAX, // no view handle for scale
387    };
388
389    if view_dist < threshold && view_dist < best_dist {
390        best_axis = Some(TransformGizmoAxis::View);
391    }
392
393    state.hovered_axis = best_axis;
394}
395
396fn transform_gizmo_drag(
397    mut focus_query: Query<(Entity, &GlobalTransform, &mut Transform), With<TransformGizmoFocus>>,
398    marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,
399    all_cameras: Query<(&Camera, &GlobalTransform)>,
400    primary_window: Single<(&Window, &mut CursorOptions), With<PrimaryWindow>>,
401    mouse: Res<ButtonInput<MouseButton>>,
402    settings: Res<TransformGizmoSettings>,
403    mut state: ResMut<TransformGizmoState>,
404    mut saved_grab_mode: Local<CursorGrabMode>,
405) {
406    let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {
407        return;
408    };
409    let (window, mut cursor_opts) = primary_window.into_inner();
410    let Some(cursor_pos) = window.cursor_position() else {
411        return;
412    };
413
414    // Start drag
415    if mouse.just_pressed(MouseButton::Left) && !state.active {
416        if let Some(axis) = state.hovered_axis
417            && let Some((entity, global_tf, transform)) = focus_query.iter().next()
418        {
419            let space = effective_space(&settings);
420            let rotation = gizmo_rotation(global_tf, space);
421            let axis_dir = axis_direction(axis, rotation, cam_tf);
422            let gizmo_pos = global_tf.translation();
423
424            // Compute initial ray-plane intersection
425            let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {
426                return;
427            };
428
429            let drag_start_world = match settings.mode {
430                TransformGizmoMode::Translate => {
431                    if axis == TransformGizmoAxis::View {
432                        // View-plane translate: use camera forward as normal
433                        let plane_normal = cam_tf.forward().as_vec3();
434                        let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)
435                        else {
436                            return;
437                        };
438                        intersection
439                    } else {
440                        let plane_normal = translation_plane_normal(ray, axis_dir);
441                        let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)
442                        else {
443                            return;
444                        };
445                        let cursor_vec = intersection - gizmo_pos;
446                        cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos
447                    }
448                }
449                TransformGizmoMode::Scale => {
450                    let plane_normal = translation_plane_normal(ray, axis_dir);
451                    let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos) else {
452                        return;
453                    };
454                    let cursor_vec = intersection - gizmo_pos;
455                    cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos
456                }
457                TransformGizmoMode::Rotate => {
458                    let rot_axis = if axis == TransformGizmoAxis::View {
459                        cam_tf.forward().as_vec3()
460                    } else {
461                        axis_dir.normalize()
462                    };
463                    let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_pos) else {
464                        return;
465                    };
466                    (intersection - gizmo_pos).normalize()
467                }
468            };
469
470            state.active = true;
471            state.axis = Some(axis);
472            state.start_transform = *transform;
473            state.entity = Some(entity);
474            state.drag_start_world = drag_start_world;
475            state.gizmo_origin = gizmo_pos;
476
477            if settings.confine_cursor {
478                *saved_grab_mode = cursor_opts.grab_mode;
479                cursor_opts.grab_mode = CursorGrabMode::Confined;
480            }
481        }
482        return;
483    }
484
485    // Continue drag
486    if state.active && mouse.pressed(MouseButton::Left) {
487        let Some(drag_entity) = state.entity else {
488            return;
489        };
490        let Some(axis) = state.axis else {
491            return;
492        };
493        let Ok((_, global_tf, mut transform)) = focus_query.get_mut(drag_entity) else {
494            return;
495        };
496
497        let space = effective_space(&settings);
498        let rotation = gizmo_rotation(global_tf, space);
499        let axis_dir = axis_direction(axis, rotation, cam_tf);
500        let gizmo_origin = state.gizmo_origin;
501
502        let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {
503            return;
504        };
505
506        match settings.mode {
507            TransformGizmoMode::Translate => {
508                if axis == TransformGizmoAxis::View {
509                    // View-plane translate
510                    let plane_normal = cam_tf.forward().as_vec3();
511                    let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)
512                    else {
513                        return;
514                    };
515                    let delta = intersection - state.drag_start_world;
516                    let new_pos = state.start_transform.translation + delta;
517                    transform.translation = match settings.snap_translate {
518                        Some(inc) => Vec3::new(
519                            snap_value(new_pos.x, inc),
520                            snap_value(new_pos.y, inc),
521                            snap_value(new_pos.z, inc),
522                        ),
523                        None => new_pos,
524                    };
525                } else {
526                    let plane_normal = translation_plane_normal(ray, axis_dir);
527                    let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)
528                    else {
529                        return;
530                    };
531                    let cursor_vec = intersection - gizmo_origin;
532                    let axis_norm = axis_dir.normalize();
533                    let new_projected = cursor_vec.dot(axis_norm) * axis_norm + gizmo_origin;
534                    let delta = new_projected - state.drag_start_world;
535
536                    transform.translation = match settings.snap_translate {
537                        Some(inc) => {
538                            state.start_transform.translation
539                                + axis_norm * snap_value(delta.dot(axis_norm), inc)
540                        }
541                        None => state.start_transform.translation + delta,
542                    };
543                }
544            }
545            TransformGizmoMode::Rotate => {
546                let rot_axis = if axis == TransformGizmoAxis::View {
547                    cam_tf.forward().as_vec3()
548                } else {
549                    axis_dir.normalize()
550                };
551                let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_origin) else {
552                    return;
553                };
554                let cursor_vector = (intersection - gizmo_origin).normalize();
555                let drag_start = state.drag_start_world; // normalized direction
556
557                let dot = drag_start.dot(cursor_vector);
558                let det = rot_axis.dot(drag_start.cross(cursor_vector));
559                let raw_angle = bevy_math::ops::atan2(det, dot);
560                let angle = match settings.snap_rotate {
561                    Some(inc) => snap_value(raw_angle, inc),
562                    None => raw_angle,
563                };
564                let rotation_delta = Quat::from_axis_angle(rot_axis, angle);
565                transform.rotation = rotation_delta * state.start_transform.rotation;
566            }
567            TransformGizmoMode::Scale => {
568                let plane_normal = translation_plane_normal(ray, axis_dir);
569                let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin) else {
570                    return;
571                };
572                let axis_norm = axis_dir.normalize();
573                let cursor_projected = (intersection - gizmo_origin).dot(axis_norm);
574                let start_projected = (state.drag_start_world - gizmo_origin).dot(axis_norm);
575
576                let scale_factor = if start_projected.abs() > f32::EPSILON {
577                    cursor_projected / start_projected
578                } else {
579                    1.0
580                };
581
582                let mut new_scale = state.start_transform.scale;
583                match axis {
584                    TransformGizmoAxis::X => {
585                        new_scale.x = (new_scale.x * scale_factor).max(MIN_SCALE);
586                    }
587                    TransformGizmoAxis::Y => {
588                        new_scale.y = (new_scale.y * scale_factor).max(MIN_SCALE);
589                    }
590                    TransformGizmoAxis::Z => {
591                        new_scale.z = (new_scale.z * scale_factor).max(MIN_SCALE);
592                    }
593                    TransformGizmoAxis::View => {
594                        // Uniform scale on view axis
595                        new_scale *= scale_factor;
596                        new_scale = new_scale.max(Vec3::splat(MIN_SCALE));
597                    }
598                }
599                transform.scale = match settings.snap_scale {
600                    Some(inc) => {
601                        let mut snapped = state.start_transform.scale;
602                        match axis {
603                            TransformGizmoAxis::X => {
604                                snapped.x = snap_value(new_scale.x, inc).max(inc);
605                            }
606                            TransformGizmoAxis::Y => {
607                                snapped.y = snap_value(new_scale.y, inc).max(inc);
608                            }
609                            TransformGizmoAxis::Z => {
610                                snapped.z = snap_value(new_scale.z, inc).max(inc);
611                            }
612                            TransformGizmoAxis::View => {
613                                snapped = Vec3::splat(snap_value(new_scale.x, inc));
614                                snapped = snapped.max(Vec3::splat(inc));
615                            }
616                        }
617                        snapped
618                    }
619                    None => new_scale,
620                };
621            }
622        }
623        return;
624    }
625
626    // End drag -- use !pressed instead of just_released for robustness (Alt-Tab, etc.)
627    if state.active && !mouse.pressed(MouseButton::Left) {
628        state.active = false;
629        state.axis = None;
630        state.entity = None;
631        if settings.confine_cursor {
632            cursor_opts.grab_mode = *saved_grab_mode;
633        }
634    }
635}
636
637/// Get the world-space direction for a given axis.
638pub fn axis_direction(axis: TransformGizmoAxis, rotation: Quat, cam_tf: &GlobalTransform) -> Vec3 {
639    match axis {
640        TransformGizmoAxis::X => rotation * Vec3::X,
641        TransformGizmoAxis::Y => rotation * Vec3::Y,
642        TransformGizmoAxis::Z => rotation * Vec3::Z,
643        TransformGizmoAxis::View => cam_tf.forward().as_vec3(),
644    }
645}
646
647/// Construct the constraint plane normal for axis translation/scale.
648///
649/// The plane contains the drag axis and is oriented to face the camera as much
650/// as possible, matching the approach from `bevy_transform_gizmo`.
651pub fn translation_plane_normal(ray: Ray3d, axis: Vec3) -> Vec3 {
652    let vertical = Vec3::from(ray.direction).cross(axis);
653    if vertical.length_squared() < f32::EPSILON {
654        // Ray is nearly parallel to the axis -- pick an arbitrary perpendicular.
655        return axis.any_orthonormal_vector();
656    }
657    axis.cross(vertical.normalize()).normalize()
658}
659
660/// Intersect a ray with a plane defined by a normal and a point on the plane.
661pub fn intersect_plane(ray: Ray3d, plane_normal: Vec3, plane_origin: Vec3) -> Option<Vec3> {
662    let denominator = Vec3::from(ray.direction).dot(plane_normal);
663    if denominator.abs() > f32::EPSILON {
664        let point_to_point = plane_origin - ray.origin;
665        let intersect_dist = plane_normal.dot(point_to_point) / denominator;
666        Some(Vec3::from(ray.direction) * intersect_dist + ray.origin)
667    } else {
668        None
669    }
670}
671
672/// Distance from a point to a line segment in 2D.
673pub fn point_to_segment_dist(point: Vec2, a: Vec2, b: Vec2) -> f32 {
674    let ab = b - a;
675    let ap = point - a;
676    let t = (ap.dot(ab) / ab.length_squared()).clamp(0.0, 1.0);
677    let closest = a + ab * t;
678    (point - closest).length()
679}
680
681/// Minimum screen-space distance from a cursor position to a 3D ring projected onto screen.
682pub fn point_to_ring_screen_dist(
683    cursor: Vec2,
684    camera: &Camera,
685    cam_tf: &GlobalTransform,
686    center: Vec3,
687    normal: Vec3,
688    radius: f32,
689) -> f32 {
690    // Quick reject: if cursor is far from the ring center in screen space, skip sampling
691    if let Ok(center_screen) = camera.world_to_viewport(cam_tf, center)
692        && let Ok(edge_screen) = camera.world_to_viewport(cam_tf, center + cam_tf.right() * radius)
693    {
694        let screen_radius = (edge_screen - center_screen).length();
695        let cursor_dist = (cursor - center_screen).length();
696        if (cursor_dist - screen_radius).abs() > screen_radius * 0.5 {
697            return f32::MAX;
698        }
699    }
700
701    const RING_SAMPLES: usize = 64;
702    let rot = Quat::from_rotation_arc(Vec3::Z, normal);
703    let mut min_dist = f32::MAX;
704    let mut prev_screen = None;
705
706    for i in 0..=RING_SAMPLES {
707        let angle = (i % RING_SAMPLES) as f32 * core::f32::consts::TAU / RING_SAMPLES as f32;
708        let local = Vec3::new(
709            bevy_math::ops::cos(angle) * radius,
710            bevy_math::ops::sin(angle) * radius,
711            0.0,
712        );
713        let world = center + rot * local;
714        let Some(screen) = camera.world_to_viewport(cam_tf, world).ok() else {
715            prev_screen = None;
716            continue;
717        };
718        if let Some(prev) = prev_screen {
719            let dist = point_to_segment_dist(cursor, prev, screen);
720            if dist < min_dist {
721                min_dist = dist;
722            }
723        }
724        prev_screen = Some(screen);
725    }
726
727    min_dist
728}
729
730/// Return the effective space for the gizmo: scale always uses local space.
731pub fn effective_space(settings: &TransformGizmoSettings) -> &TransformGizmoSpace {
732    if settings.mode == TransformGizmoMode::Scale {
733        &TransformGizmoSpace::Local
734    } else {
735        &settings.space
736    }
737}
738
739/// Compute the gizmo rotation based on the space setting.
740pub fn gizmo_rotation(global_tf: &GlobalTransform, space: &TransformGizmoSpace) -> Quat {
741    match space {
742        TransformGizmoSpace::World => Quat::IDENTITY,
743        TransformGizmoSpace::Local => {
744            let (_, rotation, _) = global_tf.to_scale_rotation_translation();
745            rotation
746        }
747    }
748}
749
750fn snap_value(value: f32, increment: f32) -> f32 {
751    (value / increment).round() * increment
752}