Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/crates/bevy_gizmos/src/transform_gizmo.rs
30635 views
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
19
use bevy_app::{App, Plugin, PostUpdate};
20
use bevy_camera::Camera;
21
use bevy_color::Color;
22
use 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
};
31
use bevy_input::{mouse::MouseButton, ButtonInput};
32
use bevy_math::{Quat, Ray3d, Vec2, Vec3};
33
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
34
use bevy_transform::components::{GlobalTransform, Transform};
35
use bevy_transform::TransformSystems;
36
use bevy_window::{CursorGrabMode, CursorOptions, PrimaryWindow, Window};
37
38
/// Default length of each axis handle.
39
pub const AXIS_LENGTH: f32 = 1.0;
40
/// Length of the arrow tip on translate handles.
41
pub const AXIS_TIP_LENGTH: f32 = 0.2;
42
/// Gap between the gizmo center and the start of each axis handle.
43
pub const AXIS_START_OFFSET: f32 = 0.2;
44
/// Default radius of the rotation rings.
45
pub const ROTATE_RING_RADIUS: f32 = 1.0;
46
/// Half-size of the scale cube tip.
47
pub const SCALE_CUBE_SIZE: f32 = 0.07;
48
49
/// Color for the X axis (magenta-pink).
50
pub const COLOR_X: Color = Color::srgb(1.0, 0.0, 0.49);
51
/// Color for the Y axis (green).
52
pub const COLOR_Y: Color = Color::srgb(0.0, 1.0, 0.49);
53
/// Color for the Z axis (blue).
54
pub const COLOR_Z: Color = Color::srgb(0.0, 0.49, 1.0);
55
/// Color for the view-plane handle (white).
56
pub const COLOR_VIEW: Color = Color::WHITE;
57
/// Alpha value used for inactive (non-hovered) axes during a drag.
58
pub const INACTIVE_ALPHA: f32 = 0.5;
59
60
const MIN_SCALE: f32 = 0.01;
61
/// Default screen-space pixel distance threshold for hover detection.
62
pub const AXIS_HIT_DISTANCE: f32 = 35.0;
63
64
/// Radius of the cylinder mesh used for axis shafts.
65
pub const SHAFT_RADIUS: f32 = 0.015;
66
/// Height of the cylinder mesh used for axis shafts.
67
pub const SHAFT_LENGTH: f32 = 0.6;
68
/// Radius of the cone mesh used for translate arrow tips.
69
pub const CONE_RADIUS: f32 = 0.05;
70
/// Height of the cone mesh used for translate arrow tips.
71
pub const CONE_HEIGHT: f32 = 0.2;
72
/// Minor (tube) radius of the view-plane circle torus.
73
pub const VIEW_CIRCLE_MINOR: f32 = 0.01;
74
/// Major (ring) radius of the view-plane circle torus.
75
pub const VIEW_CIRCLE_MAJOR: f32 = 0.15;
76
/// Minor (tube) radius of the view-axis rotation ring torus.
77
pub const VIEW_RING_MINOR: f32 = 0.01;
78
/// Major (ring) radius of the view-axis rotation ring torus.
79
pub 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)]
88
pub 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)]
98
pub struct TransformGizmoCamera;
99
100
/// Which manipulation mode the gizmo is in.
101
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Reflect)]
102
pub 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)]
114
pub 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)]
124
pub 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)]
138
pub 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
161
impl 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)]
181
pub 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)]
206
pub struct TransformGizmoSystems;
207
208
/// Marker component for the root entity of the gizmo mesh hierarchy.
209
#[derive(Component, Debug, Default, Clone, Copy)]
210
pub struct TransformGizmoRoot;
211
212
/// Marker component for individual gizmo mesh parts.
213
#[derive(Component, Debug, Clone, Copy)]
214
pub 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.
226
pub struct TransformGizmoPlugin;
227
228
impl 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]
253
macro_rules! resolve_gizmo_camera {
254
($marked:expr, $all:expr) => {{
255
let mut marked_iter = $marked.iter();
256
if let ::core::option::Option::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
::core::option::Option::Some(first)
264
} else {
265
let mut all_iter = $all.iter();
266
match (all_iter.next(), all_iter.next()) {
267
(::core::option::Option::Some(cam), ::core::option::Option::None) => {
268
::core::option::Option::Some(cam)
269
}
270
(::core::option::Option::Some(_), ::core::option::Option::Some(_)) => {
271
bevy_log::warn_once!(
272
"Multiple cameras exist but none has the TransformGizmoCamera \
273
component. Add TransformGizmoCamera to the camera the gizmo \
274
should use."
275
);
276
::core::option::Option::None
277
}
278
_ => ::core::option::Option::None,
279
}
280
}
281
}};
282
}
283
284
fn transform_gizmo_hover(
285
focus: Option<Single<&GlobalTransform, With<TransformGizmoFocus>>>,
286
marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,
287
all_cameras: Query<(&Camera, &GlobalTransform)>,
288
window: Single<&Window, With<PrimaryWindow>>,
289
settings: Res<TransformGizmoSettings>,
290
mut state: ResMut<TransformGizmoState>,
291
) {
292
state.hovered_axis = None;
293
294
if state.active {
295
return;
296
}
297
298
let Some(global_tf) = focus else {
299
return;
300
};
301
let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {
302
return;
303
};
304
let Some(cursor_pos) = window.cursor_position() else {
305
return;
306
};
307
308
let gizmo_pos = global_tf.translation();
309
let space = effective_space(&settings);
310
let rotation = gizmo_rotation(*global_tf, space);
311
312
let scale = if settings.screen_scale_factor > 0.0 {
313
(cam_tf.translation() - gizmo_pos).length() * settings.screen_scale_factor
314
} else {
315
1.0
316
};
317
318
let axes = [
319
(TransformGizmoAxis::X, rotation * Vec3::X),
320
(TransformGizmoAxis::Y, rotation * Vec3::Y),
321
(TransformGizmoAxis::Z, rotation * Vec3::Z),
322
];
323
324
let mut best_axis = None;
325
let mut best_dist = f32::MAX;
326
let threshold = settings.axis_hit_distance;
327
328
for (axis, dir) in &axes {
329
let dist = match settings.mode {
330
TransformGizmoMode::Translate | TransformGizmoMode::Scale => {
331
let start = gizmo_pos + *dir * (AXIS_START_OFFSET * scale);
332
let endpoint = gizmo_pos + *dir * (settings.axis_length * scale);
333
let Some(start_screen) = camera.world_to_viewport(cam_tf, start).ok() else {
334
continue;
335
};
336
let Some(end_screen) = camera.world_to_viewport(cam_tf, endpoint).ok() else {
337
continue;
338
};
339
point_to_segment_dist(cursor_pos, start_screen, end_screen)
340
}
341
TransformGizmoMode::Rotate => point_to_ring_screen_dist(
342
cursor_pos,
343
camera,
344
cam_tf,
345
gizmo_pos,
346
*dir,
347
settings.rotate_ring_radius * scale,
348
),
349
};
350
if dist < threshold && dist < best_dist {
351
best_dist = dist;
352
best_axis = Some(*axis);
353
}
354
}
355
356
// View handle hover detection
357
let view_dist = match settings.mode {
358
TransformGizmoMode::Translate => {
359
// Check if cursor is within the view-circle radius in screen space
360
if let Ok(center_screen) = camera.world_to_viewport(cam_tf, gizmo_pos) {
361
let screen_radius = VIEW_CIRCLE_MAJOR * scale;
362
// Approximate screen-space radius: project a point on the circle edge
363
let edge_world = gizmo_pos + cam_tf.right() * screen_radius;
364
if let Ok(edge_screen) = camera.world_to_viewport(cam_tf, edge_world) {
365
let r = (edge_screen - center_screen).length();
366
let d = (cursor_pos - center_screen).length();
367
// Hit if within the torus ring area
368
(d - r).abs()
369
} else {
370
f32::MAX
371
}
372
} else {
373
f32::MAX
374
}
375
}
376
TransformGizmoMode::Rotate => {
377
// View ring: check distance to a screen-space circle
378
let cam_forward = cam_tf.forward().as_vec3();
379
point_to_ring_screen_dist(
380
cursor_pos,
381
camera,
382
cam_tf,
383
gizmo_pos,
384
cam_forward,
385
VIEW_RING_MAJOR * scale,
386
)
387
}
388
TransformGizmoMode::Scale => f32::MAX, // no view handle for scale
389
};
390
391
if view_dist < threshold && view_dist < best_dist {
392
best_axis = Some(TransformGizmoAxis::View);
393
}
394
395
state.hovered_axis = best_axis;
396
}
397
398
fn transform_gizmo_drag(
399
mut focus_query: Query<(Entity, &GlobalTransform, &mut Transform), With<TransformGizmoFocus>>,
400
marked_cameras: Query<(&Camera, &GlobalTransform), With<TransformGizmoCamera>>,
401
all_cameras: Query<(&Camera, &GlobalTransform)>,
402
primary_window: Single<(&Window, &mut CursorOptions), With<PrimaryWindow>>,
403
mouse: Res<ButtonInput<MouseButton>>,
404
settings: Res<TransformGizmoSettings>,
405
mut state: ResMut<TransformGizmoState>,
406
mut saved_grab_mode: Local<CursorGrabMode>,
407
) {
408
let Some((camera, cam_tf)) = resolve_gizmo_camera!(marked_cameras, all_cameras) else {
409
return;
410
};
411
let (window, mut cursor_opts) = primary_window.into_inner();
412
let Some(cursor_pos) = window.cursor_position() else {
413
return;
414
};
415
416
// Start drag
417
if mouse.just_pressed(MouseButton::Left) && !state.active {
418
if let Some(axis) = state.hovered_axis
419
&& let Some((entity, global_tf, transform)) = focus_query.iter().next()
420
{
421
let space = effective_space(&settings);
422
let rotation = gizmo_rotation(global_tf, space);
423
let axis_dir = axis_direction(axis, rotation, cam_tf);
424
let gizmo_pos = global_tf.translation();
425
426
// Compute initial ray-plane intersection
427
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {
428
return;
429
};
430
431
let drag_start_world = match settings.mode {
432
TransformGizmoMode::Translate => {
433
if axis == TransformGizmoAxis::View {
434
// View-plane translate: use camera forward as normal
435
let plane_normal = cam_tf.forward().as_vec3();
436
let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)
437
else {
438
return;
439
};
440
intersection
441
} else {
442
let plane_normal = translation_plane_normal(ray, axis_dir);
443
let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos)
444
else {
445
return;
446
};
447
let cursor_vec = intersection - gizmo_pos;
448
cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos
449
}
450
}
451
TransformGizmoMode::Scale => {
452
let plane_normal = translation_plane_normal(ray, axis_dir);
453
let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_pos) else {
454
return;
455
};
456
let cursor_vec = intersection - gizmo_pos;
457
cursor_vec.dot(axis_dir.normalize()) * axis_dir.normalize() + gizmo_pos
458
}
459
TransformGizmoMode::Rotate => {
460
let rot_axis = if axis == TransformGizmoAxis::View {
461
cam_tf.forward().as_vec3()
462
} else {
463
axis_dir.normalize()
464
};
465
let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_pos) else {
466
return;
467
};
468
(intersection - gizmo_pos).normalize()
469
}
470
};
471
472
state.active = true;
473
state.axis = Some(axis);
474
state.start_transform = *transform;
475
state.entity = Some(entity);
476
state.drag_start_world = drag_start_world;
477
state.gizmo_origin = gizmo_pos;
478
479
if settings.confine_cursor {
480
*saved_grab_mode = cursor_opts.grab_mode;
481
cursor_opts.grab_mode = CursorGrabMode::Confined;
482
}
483
}
484
return;
485
}
486
487
// Continue drag
488
if state.active && mouse.pressed(MouseButton::Left) {
489
let Some(drag_entity) = state.entity else {
490
return;
491
};
492
let Some(axis) = state.axis else {
493
return;
494
};
495
let Ok((_, global_tf, mut transform)) = focus_query.get_mut(drag_entity) else {
496
return;
497
};
498
499
let space = effective_space(&settings);
500
let rotation = gizmo_rotation(global_tf, space);
501
let axis_dir = axis_direction(axis, rotation, cam_tf);
502
let gizmo_origin = state.gizmo_origin;
503
504
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor_pos) else {
505
return;
506
};
507
508
match settings.mode {
509
TransformGizmoMode::Translate => {
510
if axis == TransformGizmoAxis::View {
511
// View-plane translate
512
let plane_normal = cam_tf.forward().as_vec3();
513
let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)
514
else {
515
return;
516
};
517
let delta = intersection - state.drag_start_world;
518
let new_pos = state.start_transform.translation + delta;
519
transform.translation = match settings.snap_translate {
520
Some(inc) => Vec3::new(
521
snap_value(new_pos.x, inc),
522
snap_value(new_pos.y, inc),
523
snap_value(new_pos.z, inc),
524
),
525
None => new_pos,
526
};
527
} else {
528
let plane_normal = translation_plane_normal(ray, axis_dir);
529
let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin)
530
else {
531
return;
532
};
533
let cursor_vec = intersection - gizmo_origin;
534
let axis_norm = axis_dir.normalize();
535
let new_projected = cursor_vec.dot(axis_norm) * axis_norm + gizmo_origin;
536
let delta = new_projected - state.drag_start_world;
537
538
let new_pos = state.start_transform.translation + delta;
539
transform.translation = match settings.snap_translate {
540
Some(inc) => {
541
snap_axis(new_pos, state.start_transform.translation, axis, inc)
542
}
543
None => new_pos,
544
};
545
}
546
}
547
TransformGizmoMode::Rotate => {
548
let rot_axis = if axis == TransformGizmoAxis::View {
549
cam_tf.forward().as_vec3()
550
} else {
551
axis_dir.normalize()
552
};
553
let Some(intersection) = intersect_plane(ray, rot_axis, gizmo_origin) else {
554
return;
555
};
556
let cursor_vector = (intersection - gizmo_origin).normalize();
557
let drag_start = state.drag_start_world; // normalized direction
558
559
let dot = drag_start.dot(cursor_vector);
560
let det = rot_axis.dot(drag_start.cross(cursor_vector));
561
let raw_angle = bevy_math::ops::atan2(det, dot);
562
let angle = match settings.snap_rotate {
563
Some(inc) => snap_value(raw_angle, inc),
564
None => raw_angle,
565
};
566
let rotation_delta = Quat::from_axis_angle(rot_axis, angle);
567
transform.rotation = rotation_delta * state.start_transform.rotation;
568
}
569
TransformGizmoMode::Scale => {
570
let plane_normal = translation_plane_normal(ray, axis_dir);
571
let Some(intersection) = intersect_plane(ray, plane_normal, gizmo_origin) else {
572
return;
573
};
574
let axis_norm = axis_dir.normalize();
575
let cursor_projected = (intersection - gizmo_origin).dot(axis_norm);
576
let start_projected = (state.drag_start_world - gizmo_origin).dot(axis_norm);
577
578
let scale_factor = if start_projected.abs() > f32::EPSILON {
579
cursor_projected / start_projected
580
} else {
581
1.0
582
};
583
584
let mut new_scale = state.start_transform.scale;
585
match axis {
586
TransformGizmoAxis::X => {
587
new_scale.x = (new_scale.x * scale_factor).max(MIN_SCALE);
588
}
589
TransformGizmoAxis::Y => {
590
new_scale.y = (new_scale.y * scale_factor).max(MIN_SCALE);
591
}
592
TransformGizmoAxis::Z => {
593
new_scale.z = (new_scale.z * scale_factor).max(MIN_SCALE);
594
}
595
TransformGizmoAxis::View => {
596
// Uniform scale on view axis
597
new_scale *= scale_factor;
598
new_scale = new_scale.max(Vec3::splat(MIN_SCALE));
599
}
600
}
601
transform.scale = match settings.snap_scale {
602
Some(inc) => {
603
let mut snapped = state.start_transform.scale;
604
match axis {
605
TransformGizmoAxis::X => snapped.x = snap_value(new_scale.x, inc),
606
TransformGizmoAxis::Y => snapped.y = snap_value(new_scale.y, inc),
607
TransformGizmoAxis::Z => snapped.z = snap_value(new_scale.z, inc),
608
TransformGizmoAxis::View => {
609
snapped = Vec3::splat(snap_value(new_scale.x, inc));
610
}
611
}
612
snapped
613
}
614
None => new_scale,
615
};
616
}
617
}
618
return;
619
}
620
621
// End drag -- use !pressed instead of just_released for robustness (Alt-Tab, etc.)
622
if state.active && !mouse.pressed(MouseButton::Left) {
623
state.active = false;
624
state.axis = None;
625
state.entity = None;
626
if settings.confine_cursor {
627
cursor_opts.grab_mode = *saved_grab_mode;
628
}
629
}
630
}
631
632
/// Get the world-space direction for a given axis.
633
pub fn axis_direction(axis: TransformGizmoAxis, rotation: Quat, cam_tf: &GlobalTransform) -> Vec3 {
634
match axis {
635
TransformGizmoAxis::X => rotation * Vec3::X,
636
TransformGizmoAxis::Y => rotation * Vec3::Y,
637
TransformGizmoAxis::Z => rotation * Vec3::Z,
638
TransformGizmoAxis::View => cam_tf.forward().as_vec3(),
639
}
640
}
641
642
/// Construct the constraint plane normal for axis translation/scale.
643
///
644
/// The plane contains the drag axis and is oriented to face the camera as much
645
/// as possible, matching the approach from `bevy_transform_gizmo`.
646
pub fn translation_plane_normal(ray: Ray3d, axis: Vec3) -> Vec3 {
647
let vertical = Vec3::from(ray.direction).cross(axis);
648
if vertical.length_squared() < f32::EPSILON {
649
// Ray is nearly parallel to the axis -- pick an arbitrary perpendicular.
650
return axis.any_orthonormal_vector();
651
}
652
axis.cross(vertical.normalize()).normalize()
653
}
654
655
/// Intersect a ray with a plane defined by a normal and a point on the plane.
656
pub fn intersect_plane(ray: Ray3d, plane_normal: Vec3, plane_origin: Vec3) -> Option<Vec3> {
657
let denominator = Vec3::from(ray.direction).dot(plane_normal);
658
if denominator.abs() > f32::EPSILON {
659
let point_to_point = plane_origin - ray.origin;
660
let intersect_dist = plane_normal.dot(point_to_point) / denominator;
661
Some(Vec3::from(ray.direction) * intersect_dist + ray.origin)
662
} else {
663
None
664
}
665
}
666
667
/// Distance from a point to a line segment in 2D.
668
pub fn point_to_segment_dist(point: Vec2, a: Vec2, b: Vec2) -> f32 {
669
let ab = b - a;
670
let ap = point - a;
671
let t = (ap.dot(ab) / ab.length_squared()).clamp(0.0, 1.0);
672
let closest = a + ab * t;
673
(point - closest).length()
674
}
675
676
/// Minimum screen-space distance from a cursor position to a 3D ring projected onto screen.
677
pub fn point_to_ring_screen_dist(
678
cursor: Vec2,
679
camera: &Camera,
680
cam_tf: &GlobalTransform,
681
center: Vec3,
682
normal: Vec3,
683
radius: f32,
684
) -> f32 {
685
// Quick reject: if cursor is far from the ring center in screen space, skip sampling
686
if let Ok(center_screen) = camera.world_to_viewport(cam_tf, center)
687
&& let Ok(edge_screen) = camera.world_to_viewport(cam_tf, center + cam_tf.right() * radius)
688
{
689
let screen_radius = (edge_screen - center_screen).length();
690
let cursor_dist = (cursor - center_screen).length();
691
if (cursor_dist - screen_radius).abs() > screen_radius * 0.5 {
692
return f32::MAX;
693
}
694
}
695
696
const RING_SAMPLES: usize = 64;
697
let rot = Quat::from_rotation_arc(Vec3::Z, normal);
698
let mut min_dist = f32::MAX;
699
let mut prev_screen = None;
700
701
for i in 0..=RING_SAMPLES {
702
let angle = (i % RING_SAMPLES) as f32 * core::f32::consts::TAU / RING_SAMPLES as f32;
703
let local = Vec3::new(
704
bevy_math::ops::cos(angle) * radius,
705
bevy_math::ops::sin(angle) * radius,
706
0.0,
707
);
708
let world = center + rot * local;
709
let Some(screen) = camera.world_to_viewport(cam_tf, world).ok() else {
710
prev_screen = None;
711
continue;
712
};
713
if let Some(prev) = prev_screen {
714
let dist = point_to_segment_dist(cursor, prev, screen);
715
if dist < min_dist {
716
min_dist = dist;
717
}
718
}
719
prev_screen = Some(screen);
720
}
721
722
min_dist
723
}
724
725
/// Return the effective space for the gizmo: scale always uses local space.
726
pub fn effective_space(settings: &TransformGizmoSettings) -> &TransformGizmoSpace {
727
if settings.mode == TransformGizmoMode::Scale {
728
&TransformGizmoSpace::Local
729
} else {
730
&settings.space
731
}
732
}
733
734
/// Compute the gizmo rotation based on the space setting.
735
pub fn gizmo_rotation(global_tf: &GlobalTransform, space: &TransformGizmoSpace) -> Quat {
736
match space {
737
TransformGizmoSpace::World => Quat::IDENTITY,
738
TransformGizmoSpace::Local => {
739
let (_, rotation, _) = global_tf.to_scale_rotation_translation();
740
rotation
741
}
742
}
743
}
744
745
fn snap_value(value: f32, increment: f32) -> f32 {
746
(value / increment).round() * increment
747
}
748
749
/// Snap only the component along the dragged axis, leaving others unchanged.
750
fn snap_axis(position: Vec3, original: Vec3, axis: TransformGizmoAxis, increment: f32) -> Vec3 {
751
match axis {
752
TransformGizmoAxis::X => {
753
Vec3::new(snap_value(position.x, increment), original.y, original.z)
754
}
755
TransformGizmoAxis::Y => {
756
Vec3::new(original.x, snap_value(position.y, increment), original.z)
757
}
758
TransformGizmoAxis::Z => {
759
Vec3::new(original.x, original.y, snap_value(position.z, increment))
760
}
761
TransformGizmoAxis::View => {
762
// Snap all axes uniformly
763
Vec3::new(
764
snap_value(position.x, increment),
765
snap_value(position.y, increment),
766
snap_value(position.z, increment),
767
)
768
}
769
}
770
}
771
772