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, 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
38pub const AXIS_LENGTH: f32 = 1.0;
40pub const AXIS_TIP_LENGTH: f32 = 0.2;
42pub const AXIS_START_OFFSET: f32 = 0.2;
44pub const ROTATE_RING_RADIUS: f32 = 1.0;
46pub const SCALE_CUBE_SIZE: f32 = 0.07;
48
49pub const COLOR_X: Color = Color::srgb(1.0, 0.0, 0.49);
51pub const COLOR_Y: Color = Color::srgb(0.0, 1.0, 0.49);
53pub const COLOR_Z: Color = Color::srgb(0.0, 0.49, 1.0);
55pub const COLOR_VIEW: Color = Color::WHITE;
57pub const INACTIVE_ALPHA: f32 = 0.5;
59
60const MIN_SCALE: f32 = 0.01;
61pub const AXIS_HIT_DISTANCE: f32 = 35.0;
63
64pub const SHAFT_RADIUS: f32 = 0.015;
66pub const SHAFT_LENGTH: f32 = 0.6;
68pub const CONE_RADIUS: f32 = 0.05;
70pub const CONE_HEIGHT: f32 = 0.2;
72pub const VIEW_CIRCLE_MINOR: f32 = 0.01;
74pub const VIEW_CIRCLE_MAJOR: f32 = 0.15;
76pub const VIEW_RING_MINOR: f32 = 0.01;
78pub const VIEW_RING_MAJOR: f32 = 1.15;
80
81#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
86#[component(storage = "SparseSet")]
87#[reflect(Component, Default)]
88pub struct TransformGizmoFocus;
89
90#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
96#[component(storage = "SparseSet")]
97#[reflect(Component, Default)]
98pub struct TransformGizmoCamera;
99
100#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
102pub enum TransformGizmoMode {
103 #[default]
105 Translate,
106 Rotate,
108 Scale,
110}
111
112#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
114pub enum TransformGizmoSpace {
115 #[default]
117 World,
118 Local,
120}
121
122#[derive(Clone, Copy, PartialEq, Eq, Debug, Reflect)]
124pub enum TransformGizmoAxis {
125 X,
127 Y,
129 Z,
131 View,
133}
134
135#[derive(Resource, Reflect)]
137#[reflect(Resource)]
138pub struct TransformGizmoSettings {
139 pub mode: TransformGizmoMode,
141 pub space: TransformGizmoSpace,
143 pub axis_length: f32,
145 pub rotate_ring_radius: f32,
147 pub axis_hit_distance: f32,
149 pub snap_translate: Option<f32>,
151 pub snap_rotate: Option<f32>,
153 pub snap_scale: Option<f32>,
155 pub confine_cursor: bool,
157 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#[derive(Resource, Default, Reflect)]
180#[reflect(Resource, Default)]
181pub struct TransformGizmoState {
182 pub hovered_axis: Option<TransformGizmoAxis>,
184 pub active: bool,
186 pub axis: Option<TransformGizmoAxis>,
188 pub start_transform: Transform,
190 pub entity: Option<Entity>,
192 pub drag_start_world: Vec3,
194 pub gizmo_origin: Vec3,
196}
197
198#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
206pub struct TransformGizmoSystems;
207
208#[derive(Component, Debug, Default, Clone, Copy)]
210pub struct TransformGizmoRoot;
211
212#[derive(Component, Debug, Clone, Copy)]
214pub struct TransformGizmoMeshMarker {
215 pub axis: TransformGizmoAxis,
217 pub mode: TransformGizmoMode,
219}
220
221pub 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#[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 let view_dist = match settings.mode {
356 TransformGizmoMode::Translate => {
357 if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {
359 let screen_radius = VIEW_CIRCLE_MAJOR * scale;
360 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 (d - r).abs()
367 } else {
368 f32::MAX
369 }
370 } else {
371 f32::MAX
372 }
373 }
374 TransformGizmoMode::Rotate => {
375 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, };
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 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 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 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 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 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; 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 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 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
637pub 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
647pub 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 return axis.any_orthonormal_vector();
656 }
657 axis.cross(vertical.normalize()).normalize()
658}
659
660pub 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
672pub 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
681pub 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 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
730pub fn effective_space(settings: &TransformGizmoSettings) -> &TransformGizmoSpace {
732 if settings.mode == TransformGizmoMode::Scale {
733 &TransformGizmoSpace::Local
734 } else {
735 &settings.space
736 }
737}
738
739pub 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}