Path: blob/main/crates/bevy_gizmos/src/transform_gizmo.rs
30635 views
//! Interactive transform gizmo for translating, rotating, and scaling entities.1//!2//! This module provides an opt-in transform gizmo that renders visual handles on a3//! focused entity, allowing the user to click-and-drag to translate, rotate, or scale4//! it. The plugin does **not** handle keyboard input -- users set5//! [`TransformGizmoSettings::mode`] however they like (keyboard shortcuts, UI buttons,6//! gamepad, etc.).7//!8//! # Quick start9//!10//! 1. Add [`TransformGizmoPlugin`] to your app.11//! 2. Mark the camera with [`TransformGizmoCamera`].12//! 3. Tag the entity you want to manipulate with [`TransformGizmoFocus`].13//!14//! If there is exactly one camera in the world, the [`TransformGizmoCamera`] marker15//! is optional -- the gizmo will use that camera automatically. When multiple cameras16//! exist, the marker is required so the gizmo knows which one to use.1718use bevy_app::{App, Plugin, PostUpdate};19use bevy_camera::Camera;20use bevy_color::Color;21use bevy_ecs::{22component::Component,23entity::Entity,24query::With,25reflect::{ReflectComponent, ReflectResource},26resource::Resource,27schedule::{IntoScheduleConfigs, SystemSet},28system::{Local, Query, Res, ResMut, Single},29};30use bevy_input::{mouse::MouseButton, ButtonInput};31use bevy_math::{Quat, Ray3d, Vec2, Vec3};32use bevy_reflect::{std_traits::ReflectDefault, Reflect};33use bevy_transform::components::{GlobalTransform, Transform};34use bevy_transform::TransformSystems;35use bevy_window::{CursorGrabMode, CursorOptions, PrimaryWindow, Window};3637/// Default length of each axis handle.38pub const AXIS_LENGTH: f32 = 1.0;39/// Length of the arrow tip on translate handles.40pub const AXIS_TIP_LENGTH: f32 = 0.2;41/// Gap between the gizmo center and the start of each axis handle.42pub const AXIS_START_OFFSET: f32 = 0.2;43/// Default radius of the rotation rings.44pub const ROTATE_RING_RADIUS: f32 = 1.0;45/// Half-size of the scale cube tip.46pub const SCALE_CUBE_SIZE: f32 = 0.07;4748/// Color for the X axis (magenta-pink).49pub const COLOR_X: Color = Color::srgb(1.0, 0.0, 0.49);50/// Color for the Y axis (green).51pub const COLOR_Y: Color = Color::srgb(0.0, 1.0, 0.49);52/// Color for the Z axis (blue).53pub const COLOR_Z: Color = Color::srgb(0.0, 0.49, 1.0);54/// Color for the view-plane handle (white).55pub const COLOR_VIEW: Color = Color::WHITE;56/// Alpha value used for inactive (non-hovered) axes during a drag.57pub const INACTIVE_ALPHA: f32 = 0.5;5859const MIN_SCALE: f32 = 0.01;60/// Default screen-space pixel distance threshold for hover detection.61pub const AXIS_HIT_DISTANCE: f32 = 35.0;6263/// Radius of the cylinder mesh used for axis shafts.64pub const SHAFT_RADIUS: f32 = 0.015;65/// Height of the cylinder mesh used for axis shafts.66pub const SHAFT_LENGTH: f32 = 0.6;67/// Radius of the cone mesh used for translate arrow tips.68pub const CONE_RADIUS: f32 = 0.05;69/// Height of the cone mesh used for translate arrow tips.70pub const CONE_HEIGHT: f32 = 0.2;71/// Minor (tube) radius of the view-plane circle torus.72pub const VIEW_CIRCLE_MINOR: f32 = 0.01;73/// Major (ring) radius of the view-plane circle torus.74pub const VIEW_CIRCLE_MAJOR: f32 = 0.15;75/// Minor (tube) radius of the view-axis rotation ring torus.76pub const VIEW_RING_MINOR: f32 = 0.01;77/// Major (ring) radius of the view-axis rotation ring torus.78pub const VIEW_RING_MAJOR: f32 = 1.15;7980/// Component that marks the entity the transform gizmo operates on.81///82/// Only one entity should carry this at a time. If multiple entities have it,83/// the gizmo picks the first one returned by the query.84#[derive(Component, Debug, Default, Clone, Copy, Reflect)]85#[component(storage = "SparseSet")]86#[reflect(Component, Default)]87pub struct TransformGizmoFocus;8889/// Marker component for the camera the transform gizmo should use.90///91/// When exactly one camera exists, this marker is optional. When multiple cameras92/// exist, add this to the camera the gizmo should project through. If multiple93/// cameras carry this marker, the first one found is used and a warning is logged.94#[derive(Component, Debug, Default, Clone, Copy, Reflect)]95#[component(storage = "SparseSet")]96#[reflect(Component, Default)]97pub struct TransformGizmoCamera;9899/// Which manipulation mode the gizmo is in.100#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]101pub enum TransformGizmoMode {102/// Move the entity along an axis.103#[default]104Translate,105/// Rotate the entity around an axis.106Rotate,107/// Scale the entity along an axis.108Scale,109}110111/// Whether the gizmo transforms the object using world or local space axes.112#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]113pub enum TransformGizmoSpace {114/// Axes are aligned to the world.115#[default]116World,117/// Axes follow the entity's local rotation.118Local,119}120121/// Which axis the user is interacting with.122#[derive(Clone, Copy, PartialEq, Eq, Debug, Reflect)]123pub enum TransformGizmoAxis {124/// The X axis (red).125X,126/// The Y axis (green).127Y,128/// The Z axis (blue).129Z,130/// The view-plane / view-axis (white).131View,132}133134/// Configuration and preferences for the transform gizmo.135#[derive(Resource, Reflect)]136#[reflect(Resource)]137pub struct TransformGizmoSettings {138/// Which manipulation mode the gizmo is in.139pub mode: TransformGizmoMode,140/// Whether the gizmo transforms the object using world or local space axes.141pub space: TransformGizmoSpace,142/// Length of the axis handles.143pub axis_length: f32,144/// Radius of the rotation rings.145pub rotate_ring_radius: f32,146/// Screen-space pixel distance for hover detection.147pub axis_hit_distance: f32,148/// If set, translation snaps to this increment.149pub snap_translate: Option<f32>,150/// If set, rotation snaps to this increment (radians).151pub snap_rotate: Option<f32>,152/// If set, scale snaps to this increment.153pub snap_scale: Option<f32>,154/// Whether to confine the cursor during drag.155pub confine_cursor: bool,156/// Screen-space scale factor. Set to 0.0 to disable constant-size behavior.157pub screen_scale_factor: f32,158}159160impl Default for TransformGizmoSettings {161fn default() -> Self {162Self {163mode: TransformGizmoMode::default(),164space: TransformGizmoSpace::default(),165axis_length: AXIS_LENGTH,166rotate_ring_radius: ROTATE_RING_RADIUS,167axis_hit_distance: AXIS_HIT_DISTANCE,168snap_translate: None,169snap_rotate: None,170snap_scale: None,171confine_cursor: true,172screen_scale_factor: 0.1,173}174}175}176177/// Runtime state of the transform gizmo (drag and hover).178#[derive(Resource, Default, Reflect)]179#[reflect(Resource, Default)]180pub struct TransformGizmoState {181/// The axis under the cursor, if any.182pub hovered_axis: Option<TransformGizmoAxis>,183/// `true` while the user is actively dragging.184pub active: bool,185/// The axis being dragged, if any.186pub axis: Option<TransformGizmoAxis>,187/// The transform snapshot taken when the drag started.188pub start_transform: Transform,189/// The entity being dragged, if any.190pub entity: Option<Entity>,191/// World-space point (or normalized direction for rotation) where the drag started.192pub drag_start_world: Vec3,193/// World-space gizmo origin at drag start.194pub gizmo_origin: Vec3,195}196197/// System set for the transform gizmo. All transform gizmo systems run in [`PostUpdate`]198/// within this set.199///200/// Add a run condition to control when the gizmo is active:201/// ```ignore202/// app.configure_sets(Update, TransformGizmoSystems.run_if(in_state(AppState::Editor)));203/// ```204#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]205pub struct TransformGizmoSystems;206207/// Marker component for the root entity of the gizmo mesh hierarchy.208#[derive(Component, Debug, Default, Clone, Copy)]209pub struct TransformGizmoRoot;210211/// Marker component for individual gizmo mesh parts.212#[derive(Component, Debug, Clone, Copy)]213pub struct TransformGizmoMeshMarker {214/// Which axis this mesh part represents.215pub axis: TransformGizmoAxis,216/// Which mode this mesh part is used in.217pub mode: TransformGizmoMode,218}219220/// Opt-in plugin that adds the interactive transform gizmo.221///222/// This plugin registers the interaction logic (hover detection, drag handling,223/// state management). Pair it with the render plugin in `bevy_gizmos_render`224/// for mesh-based visualization.225pub struct TransformGizmoPlugin;226227impl Plugin for TransformGizmoPlugin {228fn build(&self, app: &mut App) {229app.init_resource::<TransformGizmoSettings>()230.init_resource::<TransformGizmoState>()231.register_type::<TransformGizmoFocus>()232.register_type::<TransformGizmoCamera>()233.register_type::<TransformGizmoSettings>()234.register_type::<TransformGizmoState>()235.configure_sets(PostUpdate, TransformGizmoSystems)236.add_systems(237PostUpdate,238(239transform_gizmo_drag.before(TransformSystems::Propagate),240transform_gizmo_hover.after(TransformSystems::Propagate),241)242.in_set(TransformGizmoSystems),243);244}245}246247/// Resolves which camera the gizmo should use.248///249/// Prefers cameras marked with [`TransformGizmoCamera`]. Falls back to the sole250/// camera in the world when no marker is present, and warns when ambiguous.251#[macro_export]252macro_rules! resolve_gizmo_camera {253($marked:expr, $all:expr) => {{254let mut marked_iter = $marked.iter();255if let ::core::option::Option::Some(first) = marked_iter.next() {256if marked_iter.next().is_some() {257bevy_log::warn_once!(258"Multiple cameras have the TransformGizmoCamera component; \259using the first one found."260);261}262::core::option::Option::Some(first)263} else {264let mut all_iter = $all.iter();265match (all_iter.next(), all_iter.next()) {266(::core::option::Option::Some(cam), ::core::option::Option::None) => {267::core::option::Option::Some(cam)268}269(::core::option::Option::Some(_), ::core::option::Option::Some(_)) => {270bevy_log::warn_once!(271"Multiple cameras exist but none has the TransformGizmoCamera \272component. Add TransformGizmoCamera to the camera the gizmo \273should use."274);275::core::option::Option::None276}277_ => ::core::option::Option::None,278}279}280}};281}282283fn transform_gizmo_hover(284focus: Option<Single<&GlobalTransform, With<TransformGizmoFocus>>>,285marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,286all_cameras: Query<(&Camera, &GlobalTransform)>,287window: Single<&Window, With<PrimaryWindow>>,288settings: Res<TransformGizmoSettings>,289mut state: ResMut<TransformGizmoState>,290) {291state.hovered_axis = None;292293if state.active {294return;295}296297let Some(global_tf) = focus else {298return;299};300let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {301return;302};303let Some(cursor_pos) = window.cursor_position() else {304return;305};306307let gizmo_pos = global_tf.translation();308let space = effective_space(&settings);309let rotation = gizmo_rotation(*global_tf, space);310311let scale = if settings.screen_scale_factor > 0.0 {312(cam_tf.translation() - gizmo_pos).length() * settings.screen_scale_factor313} else {3141.0315};316317let axes = [318(TransformGizmoAxis::X, rotation * Vec3::X),319(TransformGizmoAxis::Y, rotation * Vec3::Y),320(TransformGizmoAxis::Z, rotation * Vec3::Z),321];322323let mut best_axis = None;324let mut best_dist = f32::MAX;325let threshold = settings.axis_hit_distance;326327for (axis, dir) in &axes {328let dist = match settings.mode {329TransformGizmoMode::Translate | TransformGizmoMode::Scale => {330let start = gizmo_pos + *dir * (AXIS_START_OFFSET * scale);331let endpoint = gizmo_pos + *dir * (settings.axis_length * scale);332let Some(start_screen) = camera.world_to_viewport(cam_tf, start).ok() else {333continue;334};335let Some(end_screen) = camera.world_to_viewport(cam_tf, endpoint).ok() else {336continue;337};338point_to_segment_dist(cursor_pos, start_screen, end_screen)339}340TransformGizmoMode::Rotate => point_to_ring_screen_dist(341cursor_pos,342camera,343cam_tf,344gizmo_pos,345*dir,346settings.rotate_ring_radius * scale,347),348};349if dist < threshold && dist < best_dist {350best_dist = dist;351best_axis = Some(*axis);352}353}354355// View handle hover detection356let view_dist = match settings.mode {357TransformGizmoMode::Translate => {358// Check if cursor is within the view-circle radius in screen space359if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {360let screen_radius = VIEW_CIRCLE_MAJOR * scale;361// Approximate screen-space radius: project a point on the circle edge362let edge_world = gizmo_pos + cam_tf.right() * screen_radius;363if let Ok(edge_screen) = camera.world_to_viewport(cam_tf, edge_world) {364let r = (edge_screen - center_screen).length();365let d = (cursor_pos - center_screen).length();366// Hit if within the torus ring area367(d - r).abs()368} else {369f32::MAX370}371} else {372f32::MAX373}374}375TransformGizmoMode::Rotate => {376// View ring: check distance to a screen-space circle377let cam_forward = cam_tf.forward().as_vec3();378point_to_ring_screen_dist(379cursor_pos,380camera,381cam_tf,382gizmo_pos,383cam_forward,384VIEW_RING_MAJOR * scale,385)386}387TransformGizmoMode::Scale => f32::MAX, // no view handle for scale388};389390if view_dist < threshold && view_dist < best_dist {391best_axis = Some(TransformGizmoAxis::View);392}393394state.hovered_axis = best_axis;395}396397fn transform_gizmo_drag(398mut focus_query: Query<(Entity, &GlobalTransform, &mut Transform), With<TransformGizmoFocus>>,399marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,400all_cameras: Query<(&Camera, &GlobalTransform)>,401primary_window: Single<(&Window, &mut CursorOptions), With<PrimaryWindow>>,402mouse: Res<ButtonInput<MouseButton>>,403settings: Res<TransformGizmoSettings>,404mut state: ResMut<TransformGizmoState>,405mut saved_grab_mode: Local<CursorGrabMode>,406) {407let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {408return;409};410let (window, mut cursor_opts) = primary_window.into_inner();411let Some(cursor_pos) = window.cursor_position() else {412return;413};414415// Start drag416if mouse.just_pressed(MouseButton::Left) && !state.active {417if let Some(axis) = state.hovered_axis418&& let Some((entity, global_tf, transform)) = focus_query.iter().next()419{420let space = effective_space(&settings);421let rotation = gizmo_rotation(global_tf, space);422let axis_dir = axis_direction(axis, rotation, cam_tf);423let gizmo_pos = global_tf.translation();424425// Compute initial ray-plane intersection426let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {427return;428};429430let drag_start_world = match settings.mode {431TransformGizmoMode::Translate => {432if axis == TransformGizmoAxis::View {433// View-plane translate: use camera forward as normal434let plane_normal = cam_tf.forward().as_vec3();435let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)436else {437return;438};439intersection440} else {441let plane_normal = translation_plane_normal(ray, axis_dir);442let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)443else {444return;445};446let cursor_vec = intersection - gizmo_pos;447cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos448}449}450TransformGizmoMode::Scale => {451let plane_normal = translation_plane_normal(ray, axis_dir);452let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos) else {453return;454};455let cursor_vec = intersection - gizmo_pos;456cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos457}458TransformGizmoMode::Rotate => {459let rot_axis = if axis == TransformGizmoAxis::View {460cam_tf.forward().as_vec3()461} else {462axis_dir.normalize()463};464let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_pos) else {465return;466};467(intersection - gizmo_pos).normalize()468}469};470471state.active = true;472state.axis = Some(axis);473state.start_transform = *transform;474state.entity = Some(entity);475state.drag_start_world = drag_start_world;476state.gizmo_origin = gizmo_pos;477478if settings.confine_cursor {479*saved_grab_mode = cursor_opts.grab_mode;480cursor_opts.grab_mode = CursorGrabMode::Confined;481}482}483return;484}485486// Continue drag487if state.active && mouse.pressed(MouseButton::Left) {488let Some(drag_entity) = state.entity else {489return;490};491let Some(axis) = state.axis else {492return;493};494let Ok((_, global_tf, mut transform)) = focus_query.get_mut(drag_entity) else {495return;496};497498let space = effective_space(&settings);499let rotation = gizmo_rotation(global_tf, space);500let axis_dir = axis_direction(axis, rotation, cam_tf);501let gizmo_origin = state.gizmo_origin;502503let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {504return;505};506507match settings.mode {508TransformGizmoMode::Translate => {509if axis == TransformGizmoAxis::View {510// View-plane translate511let plane_normal = cam_tf.forward().as_vec3();512let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)513else {514return;515};516let delta = intersection - state.drag_start_world;517let new_pos = state.start_transform.translation + delta;518transform.translation = match settings.snap_translate {519Some(inc) => Vec3::new(520snap_value(new_pos.x, inc),521snap_value(new_pos.y, inc),522snap_value(new_pos.z, inc),523),524None => new_pos,525};526} else {527let plane_normal = translation_plane_normal(ray, axis_dir);528let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)529else {530return;531};532let cursor_vec = intersection - gizmo_origin;533let axis_norm = axis_dir.normalize();534let new_projected = cursor_vec.dot(axis_norm) * axis_norm + gizmo_origin;535let delta = new_projected - state.drag_start_world;536537let new_pos = state.start_transform.translation + delta;538transform.translation = match settings.snap_translate {539Some(inc) => {540snap_axis(new_pos, state.start_transform.translation, axis, inc)541}542None => new_pos,543};544}545}546TransformGizmoMode::Rotate => {547let rot_axis = if axis == TransformGizmoAxis::View {548cam_tf.forward().as_vec3()549} else {550axis_dir.normalize()551};552let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_origin) else {553return;554};555let cursor_vector = (intersection - gizmo_origin).normalize();556let drag_start = state.drag_start_world; // normalized direction557558let dot = drag_start.dot(cursor_vector);559let det = rot_axis.dot(drag_start.cross(cursor_vector));560let raw_angle = bevy_math::ops::atan2(det, dot);561let angle = match settings.snap_rotate {562Some(inc) => snap_value(raw_angle, inc),563None => raw_angle,564};565let rotation_delta = Quat::from_axis_angle(rot_axis, angle);566transform.rotation = rotation_delta * state.start_transform.rotation;567}568TransformGizmoMode::Scale => {569let plane_normal = translation_plane_normal(ray, axis_dir);570let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin) else {571return;572};573let axis_norm = axis_dir.normalize();574let cursor_projected = (intersection - gizmo_origin).dot(axis_norm);575let start_projected = (state.drag_start_world - gizmo_origin).dot(axis_norm);576577let scale_factor = if start_projected.abs() > f32::EPSILON {578cursor_projected / start_projected579} else {5801.0581};582583let mut new_scale = state.start_transform.scale;584match axis {585TransformGizmoAxis::X => {586new_scale.x = (new_scale.x * scale_factor).max(MIN_SCALE);587}588TransformGizmoAxis::Y => {589new_scale.y = (new_scale.y * scale_factor).max(MIN_SCALE);590}591TransformGizmoAxis::Z => {592new_scale.z = (new_scale.z * scale_factor).max(MIN_SCALE);593}594TransformGizmoAxis::View => {595// Uniform scale on view axis596new_scale *= scale_factor;597new_scale = new_scale.max(Vec3::splat(MIN_SCALE));598}599}600transform.scale = match settings.snap_scale {601Some(inc) => {602let mut snapped = state.start_transform.scale;603match axis {604TransformGizmoAxis::X => snapped.x = snap_value(new_scale.x, inc),605TransformGizmoAxis::Y => snapped.y = snap_value(new_scale.y, inc),606TransformGizmoAxis::Z => snapped.z = snap_value(new_scale.z, inc),607TransformGizmoAxis::View => {608snapped = Vec3::splat(snap_value(new_scale.x, inc));609}610}611snapped612}613None => new_scale,614};615}616}617return;618}619620// End drag -- use !pressed instead of just_released for robustness (Alt-Tab, etc.)621if state.active && !mouse.pressed(MouseButton::Left) {622state.active = false;623state.axis = None;624state.entity = None;625if settings.confine_cursor {626cursor_opts.grab_mode = *saved_grab_mode;627}628}629}630631/// Get the world-space direction for a given axis.632pub fn axis_direction(axis: TransformGizmoAxis, rotation: Quat, cam_tf: &GlobalTransform) -> Vec3 {633match axis {634TransformGizmoAxis::X => rotation * Vec3::X,635TransformGizmoAxis::Y => rotation * Vec3::Y,636TransformGizmoAxis::Z => rotation * Vec3::Z,637TransformGizmoAxis::View => cam_tf.forward().as_vec3(),638}639}640641/// Construct the constraint plane normal for axis translation/scale.642///643/// The plane contains the drag axis and is oriented to face the camera as much644/// as possible, matching the approach from `bevy_transform_gizmo`.645pub fn translation_plane_normal(ray: Ray3d, axis: Vec3) -> Vec3 {646let vertical = Vec3::from(ray.direction).cross(axis);647if vertical.length_squared() < f32::EPSILON {648// Ray is nearly parallel to the axis -- pick an arbitrary perpendicular.649return axis.any_orthonormal_vector();650}651axis.cross(vertical.normalize()).normalize()652}653654/// Intersect a ray with a plane defined by a normal and a point on the plane.655pub fn intersect_plane(ray: Ray3d, plane_normal: Vec3, plane_origin: Vec3) -> Option<Vec3> {656let denominator = Vec3::from(ray.direction).dot(plane_normal);657if denominator.abs() > f32::EPSILON {658let point_to_point = plane_origin - ray.origin;659let intersect_dist = plane_normal.dot(point_to_point) / denominator;660Some(Vec3::from(ray.direction) * intersect_dist + ray.origin)661} else {662None663}664}665666/// Distance from a point to a line segment in 2D.667pub fn point_to_segment_dist(point: Vec2, a: Vec2, b: Vec2) -> f32 {668let ab = b - a;669let ap = point - a;670let t = (ap.dot(ab) / ab.length_squared()).clamp(0.0, 1.0);671let closest = a + ab * t;672(point - closest).length()673}674675/// Minimum screen-space distance from a cursor position to a 3D ring projected onto screen.676pub fn point_to_ring_screen_dist(677cursor: Vec2,678camera: &Camera,679cam_tf: &GlobalTransform,680center: Vec3,681normal: Vec3,682radius: f32,683) -> f32 {684// Quick reject: if cursor is far from the ring center in screen space, skip sampling685if let Ok(center_screen) = camera.world_to_viewport(cam_tf, center)686&& let Ok(edge_screen) = camera.world_to_viewport(cam_tf, center + cam_tf.right() * radius)687{688let screen_radius = (edge_screen - center_screen).length();689let cursor_dist = (cursor - center_screen).length();690if (cursor_dist - screen_radius).abs() > screen_radius * 0.5 {691return f32::MAX;692}693}694695const RING_SAMPLES: usize = 64;696let rot = Quat::from_rotation_arc(Vec3::Z, normal);697let mut min_dist = f32::MAX;698let mut prev_screen = None;699700for i in 0..=RING_SAMPLES {701let angle = (i % RING_SAMPLES) as f32 * core::f32::consts::TAU / RING_SAMPLES as f32;702let local = Vec3::new(703bevy_math::ops::cos(angle) * radius,704bevy_math::ops::sin(angle) * radius,7050.0,706);707let world = center + rot * local;708let Some(screen) = camera.world_to_viewport(cam_tf, world).ok() else {709prev_screen = None;710continue;711};712if let Some(prev) = prev_screen {713let dist = point_to_segment_dist(cursor, prev, screen);714if dist < min_dist {715min_dist = dist;716}717}718prev_screen = Some(screen);719}720721min_dist722}723724/// Return the effective space for the gizmo: scale always uses local space.725pub fn effective_space(settings: &TransformGizmoSettings) -> &TransformGizmoSpace {726if settings.mode == TransformGizmoMode::Scale {727&TransformGizmoSpace::Local728} else {729&settings.space730}731}732733/// Compute the gizmo rotation based on the space setting.734pub fn gizmo_rotation(global_tf: &GlobalTransform, space: &TransformGizmoSpace) -> Quat {735match space {736TransformGizmoSpace::World => Quat::IDENTITY,737TransformGizmoSpace::Local => {738let (_, rotation, _) = global_tf.to_scale_rotation_translation();739rotation740}741}742}743744fn snap_value(value: f32, increment: f32) -> f32 {745(value / increment).round() * increment746}747748/// Snap only the component along the dragged axis, leaving others unchanged.749fn snap_axis(position: Vec3, original: Vec3, axis: TransformGizmoAxis, increment: f32) -> Vec3 {750match axis {751TransformGizmoAxis::X => {752Vec3::new(snap_value(position.x, increment), original.y, original.z)753}754TransformGizmoAxis::Y => {755Vec3::new(original.x, snap_value(position.y, increment), original.z)756}757TransformGizmoAxis::Z => {758Vec3::new(original.x, original.y, snap_value(position.z, increment))759}760TransformGizmoAxis::View => {761// Snap all axes uniformly762Vec3::new(763snap_value(position.x, increment),764snap_value(position.y, increment),765snap_value(position.z, increment),766)767}768}769}770771772