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, 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
39/// Default length of each axis handle.
40pub const AXIS_LENGTH: f32 = 1.0;
41/// Length of the arrow tip on translate handles.
42pub const AXIS_TIP_LENGTH: f32 = 0.2;
43/// Gap between the gizmo center and the start of each axis handle.
44pub const AXIS_START_OFFSET: f32 = 0.2;
45/// Default radius of the rotation rings.
46pub const ROTATE_RING_RADIUS: f32 = 1.0;
47/// Half-size of the scale cube tip.
48pub const SCALE_CUBE_SIZE: f32 = 0.07;
49
50/// Color for the X axis (magenta-pink).
51pub const COLOR_X: Color = Color::srgb(1.0, 0.0, 0.49);
52/// Color for the Y axis (green).
53pub const COLOR_Y: Color = Color::srgb(0.0, 1.0, 0.49);
54/// Color for the Z axis (blue).
55pub const COLOR_Z: Color = Color::srgb(0.0, 0.49, 1.0);
56/// Color for the view-plane handle (white).
57pub const COLOR_VIEW: Color = Color::WHITE;
58/// Alpha value used for inactive (non-hovered) axes during a drag.
59pub const INACTIVE_ALPHA: f32 = 0.5;
60
61const MIN_SCALE: f32 = 0.01;
62/// Default screen-space pixel distance threshold for hover detection.
63pub const AXIS_HIT_DISTANCE: f32 = 35.0;
64
65/// Radius of the cylinder mesh used for axis shafts.
66pub const SHAFT_RADIUS: f32 = 0.015;
67/// Height of the cylinder mesh used for axis shafts.
68pub const SHAFT_LENGTH: f32 = 0.6;
69/// Radius of the cone mesh used for translate arrow tips.
70pub const CONE_RADIUS: f32 = 0.05;
71/// Height of the cone mesh used for translate arrow tips.
72pub const CONE_HEIGHT: f32 = 0.2;
73/// Minor (tube) radius of the view-plane circle torus.
74pub const VIEW_CIRCLE_MINOR: f32 = 0.01;
75/// Major (ring) radius of the view-plane circle torus.
76pub const VIEW_CIRCLE_MAJOR: f32 = 0.15;
77/// Minor (tube) radius of the view-axis rotation ring torus.
78pub const VIEW_RING_MINOR: f32 = 0.01;
79/// Major (ring) radius of the view-axis rotation ring torus.
80pub const VIEW_RING_MAJOR: f32 = 1.15;
81
82/// Component that marks the entity the transform gizmo operates on.
83///
84/// Only one entity should carry this at a time. If multiple entities have it,
85/// the gizmo picks the first one returned by the query.
86#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
87#[component(storage = "SparseSet")]
88#[reflect(Component, Default)]
89pub struct TransformGizmoFocus;
90
91/// Marker component for the camera the transform gizmo should use.
92///
93/// When exactly one camera exists, this marker is optional. When multiple cameras
94/// exist, add this to the camera the gizmo should project through. If multiple
95/// cameras carry this marker, the first one found is used and a warning is logged.
96#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
97#[component(storage = "SparseSet")]
98#[reflect(Component, Default)]
99pub struct TransformGizmoCamera;
100
101/// Which manipulation mode the gizmo is in.
102#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
103pub enum TransformGizmoMode {
104    /// Move the entity along an axis.
105    #[default]
106    Translate,
107    /// Rotate the entity around an axis.
108    Rotate,
109    /// Scale the entity along an axis.
110    Scale,
111}
112
113/// Whether the gizmo transforms the object using world or local space axes.
114#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
115pub enum TransformGizmoSpace {
116    /// Axes are aligned to the world.
117    #[default]
118    World,
119    /// Axes follow the entity's local rotation.
120    Local,
121}
122
123/// Which axis the user is interacting with.
124#[derive(Clone, Copy, PartialEq, Eq, Debug, Reflect)]
125pub enum TransformGizmoAxis {
126    /// The X axis (red).
127    X,
128    /// The Y axis (green).
129    Y,
130    /// The Z axis (blue).
131    Z,
132    /// The view-plane / view-axis (white).
133    View,
134}
135
136/// Configuration and preferences for the transform gizmo.
137#[derive(Resource, Reflect)]
138#[reflect(Resource)]
139pub struct TransformGizmoSettings {
140    /// Which manipulation mode the gizmo is in.
141    pub mode: TransformGizmoMode,
142    /// Whether the gizmo transforms the object using world or local space axes.
143    pub space: TransformGizmoSpace,
144    /// Length of the axis handles.
145    pub axis_length: f32,
146    /// Radius of the rotation rings.
147    pub rotate_ring_radius: f32,
148    /// Screen-space pixel distance for hover detection.
149    pub axis_hit_distance: f32,
150    /// If set, translation snaps to this increment.
151    pub snap_translate: Option<f32>,
152    /// If set, rotation snaps to this increment (radians).
153    pub snap_rotate: Option<f32>,
154    /// If set, scale snaps to this increment.
155    pub snap_scale: Option<f32>,
156    /// Whether to confine the cursor during drag.
157    pub confine_cursor: bool,
158    /// Screen-space scale factor. Set to 0.0 to disable constant-size behavior.
159    pub screen_scale_factor: f32,
160    /// Controls how fast objects change scale when the scale handle is used.
161    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/// Runtime state of the transform gizmo (drag and hover).
183#[derive(Resource, Default, Reflect)]
184#[reflect(Resource, Default)]
185pub struct TransformGizmoState {
186    /// The axis under the cursor, if any.
187    pub hovered_axis: Option<TransformGizmoAxis>,
188    /// `true` while the user is actively dragging.
189    pub active: bool,
190    /// The axis being dragged, if any.
191    pub axis: Option<TransformGizmoAxis>,
192    /// The transform snapshot taken when the drag started.
193    pub start_transform: Transform,
194    /// The entity being dragged, if any.
195    pub entity: Option<Entity>,
196    /// World-space point (or normalized direction for rotation) where the drag started.
197    pub drag_start_world: Vec3,
198    /// World-space gizmo origin at drag start.
199    pub gizmo_origin: Vec3,
200}
201
202/// System set for the transform gizmo. All transform gizmo systems run in [`PostUpdate`]
203/// within this set.
204///
205/// Add a run condition to control when the gizmo is active:
206/// ```ignore
207/// app.configure_sets(Update, TransformGizmoSystems.run_if(in_state(AppState::Editor)));
208/// ```
209#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
210pub struct TransformGizmoSystems;
211
212/// Marker component for the root entity of the gizmo mesh hierarchy.
213#[derive(Component, Debug, Default, Clone, Copy)]
214pub struct TransformGizmoRoot;
215
216/// Marker component for individual gizmo mesh parts.
217#[derive(Component, Debug, Clone, Copy)]
218pub struct TransformGizmoMeshMarker {
219    /// Which axis this mesh part represents.
220    pub axis: TransformGizmoAxis,
221    /// Which mode this mesh part is used in.
222    pub mode: TransformGizmoMode,
223}
224
225/// Opt-in plugin that adds the interactive transform gizmo.
226///
227/// This plugin registers the interaction logic (hover detection, drag handling,
228/// state management). Pair it with the render plugin in `bevy_gizmos_render`
229/// for mesh-based visualization.
230pub 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/// Resolves which camera the gizmo should use.
253///
254/// Prefers cameras marked with [`TransformGizmoCamera`]. Falls back to the sole
255/// camera in the world when no marker is present, and warns when ambiguous.
256#[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    // View handle hover detection
361    let view_dist = match settings.mode {
362        TransformGizmoMode::Translate => {
363            // Check if cursor is within the view-circle radius in screen space
364            if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {
365                let screen_radius = VIEW_CIRCLE_MAJOR * scale;
366                // Approximate screen-space radius: project a point on the circle edge
367                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                    // Hit if within the torus ring area
372                    (d - r).abs()
373                } else {
374                    f32::MAX
375                }
376            } else {
377                f32::MAX
378            }
379        }
380        TransformGizmoMode::Rotate => {
381            // View ring: check distance to a screen-space circle
382            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    // Start drag
427    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            // Compute initial ray-plane intersection
437            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                        // View-plane translate: use camera forward as normal
445                        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    // Continue drag
490    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                    // View-plane translate
514                    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; // normalized direction
560
561                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                        // Uniform scale on view axis
619                        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    // End drag -- use !pressed instead of just_released for robustness (Alt-Tab, etc.)
651    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
661/// Get the world-space direction for a given axis.
662pub 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
671/// Construct the constraint plane normal for axis translation/scale.
672///
673/// The plane contains the drag axis and is oriented to face the camera as much
674/// as possible, matching the approach from `bevy_transform_gizmo`.
675pub 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        // Ray is nearly parallel to the axis -- pick an arbitrary perpendicular.
679        return axis.any_orthonormal_vector();
680    }
681    axis.cross(vertical.normalize()).normalize()
682}
683
684/// Intersect a ray with a plane defined by a normal and a point on the plane.
685pub 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
696/// Distance from a point to a line segment in 2D.
697pub 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
705/// Minimum screen-space distance from a cursor position to a 3D ring projected onto screen.
706pub 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    // Quick reject: if cursor is far from the ring center in screen space, skip sampling
715    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
754/// Return the effective space for the gizmo: scale always uses local space.
755pub fn effective_space(settings: &TransformGizmoSettings) -> &TransformGizmoSpace {
756    if settings.mode == TransformGizmoMode::Scale {
757        &TransformGizmoSpace::Local
758    } else {
759        &settings.space
760    }
761}
762
763/// Compute the gizmo rotation based on the space setting.
764pub 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}