transform_gizmo_bevy/
lib.rs

1//! A 3D transformation Gizmo for the Bevy game engine.
2//!
3//! transform-gizmo-bevy provides a feature-rich and configurable 3D transformation
4//! gizmo that can be used to manipulate entities' transforms (position, rotation, scale)
5//! visually.
6//!
7//! # Usage
8//!
9//! Add `TransformGizmoPlugin` to your App.
10//!
11//! ```ignore
12//! use bevy::prelude::*;
13//! use transform_gizmo_bevy::prelude::*;
14//!
15//! App::new()
16//!     .add_plugins(DefaultPlugins)
17//!     .add_plugins(TransformGizmoPlugin)
18//!     .run();
19//! ```
20//!
21//! Add [`GizmoCamera`] component to your Camera entity.
22//!
23//! Add [`GizmoTarget`] component to any of your entities that you would like to manipulate the [`Transform`] of.
24//!
25//! # Configuration
26//!
27//! You can configure the gizmo by modifying the [`GizmoOptions`] resource.
28//!
29//! You can either set it up with [`App::insert_resource`] when creating your App, or at any point in a system with [`ResMut<GizmoOptions>`].
30
31use bevy_app::prelude::*;
32use bevy_asset::{AssetApp, Assets};
33use bevy_ecs::prelude::*;
34use bevy_input::prelude::*;
35use bevy_math::{DQuat, DVec3, Vec2};
36use bevy_picking::hover::HoverMap;
37use bevy_platform::collections::HashMap;
38use bevy_render::prelude::*;
39use bevy_transform::prelude::*;
40use bevy_window::{PrimaryWindow, Window};
41use mouse_interact::MouseGizmoInteractionPlugin;
42use picking::TransformGizmoPickingPlugin;
43use uuid::Uuid;
44
45use render::{DrawDataHandles, TransformGizmoRenderPlugin};
46use transform_gizmo::config::{
47    DEFAULT_SNAP_ANGLE, DEFAULT_SNAP_DISTANCE, DEFAULT_SNAP_SCALE, GizmoModeKind,
48    TransformPivotPoint,
49};
50pub use transform_gizmo::{
51    GizmoConfig,
52    math::{Pos2, Rect},
53    *,
54};
55
56pub mod mouse_interact;
57pub mod picking;
58pub mod prelude;
59
60mod render;
61
62const GIZMO_GROUP_UUID: Uuid = Uuid::from_u128(0x_1c90_3d44_0152_45e1_b1c9_889a_0203_e90c);
63
64/// Adds transform gizmos to the App.
65///
66/// Gizmos are interactive tools that appear in the scene, allowing users to manipulate
67/// entities' transforms (position, rotation, scale) visually.
68pub struct TransformGizmoPlugin;
69
70impl Plugin for TransformGizmoPlugin {
71    fn build(&self, app: &mut App) {
72        app.init_asset::<render::GizmoDrawData>()
73            .init_resource::<GizmoOptions>()
74            .init_resource::<GizmoStorage>()
75            .add_event::<GizmoDragStarted>()
76            .add_event::<GizmoDragging>()
77            .add_plugins(TransformGizmoRenderPlugin)
78            .add_systems(
79                Last,
80                (handle_hotkeys, update_gizmos, draw_gizmos, cleanup_old_data).chain(),
81            );
82
83        #[cfg(feature = "gizmo_picking_backend")]
84        app.add_plugins(TransformGizmoPickingPlugin);
85        #[cfg(feature = "mouse_interaction")]
86        app.add_plugins(MouseGizmoInteractionPlugin);
87    }
88}
89
90/// Various options for configuring the transform gizmos.
91#[derive(Resource, Copy, Clone, Debug)]
92pub struct GizmoOptions {
93    /// Modes to use in the gizmos.
94    pub gizmo_modes: EnumSet<GizmoMode>,
95    /// Orientation of the gizmo. This affects the behaviour of transformations.
96    pub gizmo_orientation: GizmoOrientation,
97    /// Orientation of the gizmo. This affects the behaviour of transformations.
98    pub pivot_point: TransformPivotPoint,
99    /// Look and feel of the gizmo.
100    pub visuals: GizmoVisuals,
101    /// Whether snapping is enabled in the gizmo transformations.
102    /// This may be overwritten with hotkeys ([`GizmoHotkeys::enable_snapping`]).
103    pub snapping: bool,
104    /// When snapping is enabled, snap twice as often.
105    /// This may be overwritten with hotkeys ([`GizmoHotkeys::enable_accurate_mode`]).
106    pub accurate_mode: bool,
107    /// Angle increment for snapping rotations, in radians.
108    pub snap_angle: f32,
109    /// Distance increment for snapping translations.
110    pub snap_distance: f32,
111    /// Scale increment for snapping scalings.
112    pub snap_scale: f32,
113    /// If `true`, all [`GizmoTarget`]s are transformed
114    /// using a single gizmo. If `false`, each target
115    /// has its own gizmo.
116    pub group_targets: bool,
117    /// If set, this mode is forced active and other modes are disabled.
118    /// This may be overwritten with hotkeys.
119    pub mode_override: Option<GizmoMode>,
120    /// Hotkeys for easier interaction with the gizmo.
121    pub hotkeys: Option<GizmoHotkeys>,
122    /// Allows you to provide a custom viewport rect, which will be used to
123    /// scale the cursor position. By default, this is set to `None` which means
124    /// the full window size is used as the viewport.
125    pub viewport_rect: Option<bevy_math::Rect>,
126}
127
128impl Default for GizmoOptions {
129    fn default() -> Self {
130        Self {
131            gizmo_modes: GizmoMode::all(),
132            gizmo_orientation: GizmoOrientation::default(),
133            pivot_point: TransformPivotPoint::default(),
134            visuals: Default::default(),
135            snapping: false,
136            accurate_mode: false,
137            snap_angle: DEFAULT_SNAP_ANGLE,
138            snap_distance: DEFAULT_SNAP_DISTANCE,
139            snap_scale: DEFAULT_SNAP_SCALE,
140            group_targets: true,
141            mode_override: None,
142            hotkeys: None,
143            viewport_rect: None,
144        }
145    }
146}
147
148/// Hotkeys for easier interaction with the gizmo.
149#[derive(Debug, Copy, Clone)]
150pub struct GizmoHotkeys {
151    /// When pressed, transformations snap to according to snap values
152    /// specified in [`GizmoOptions`].
153    pub enable_snapping: Option<KeyCode>,
154    /// When pressed, snapping is twice as accurate.
155    pub enable_accurate_mode: Option<KeyCode>,
156    /// Toggles gizmo to rotate-only mode.
157    pub toggle_rotate: Option<KeyCode>,
158    /// Toggles gizmo to translate-only mode.
159    pub toggle_translate: Option<KeyCode>,
160    /// Toggles gizmo to scale-only mode.
161    pub toggle_scale: Option<KeyCode>,
162    /// Limits overridden gizmo mode to X axis only.
163    pub toggle_x: Option<KeyCode>,
164    /// Limits overridden gizmo mode to Y axis only.
165    pub toggle_y: Option<KeyCode>,
166    /// Limits overridden gizmo mode to Z axis only.
167    pub toggle_z: Option<KeyCode>,
168    /// When pressed, deactivates the gizmo if it
169    /// was active.
170    pub deactivate_gizmo: Option<KeyCode>,
171    /// If true, a mouse click deactivates the gizmo if it
172    /// was active.
173    pub mouse_click_deactivates: bool,
174}
175
176impl Default for GizmoHotkeys {
177    fn default() -> Self {
178        Self {
179            enable_snapping: Some(KeyCode::ControlLeft),
180            enable_accurate_mode: Some(KeyCode::ShiftLeft),
181            toggle_rotate: Some(KeyCode::KeyR),
182            toggle_translate: Some(KeyCode::KeyG),
183            toggle_scale: Some(KeyCode::KeyS),
184            toggle_x: Some(KeyCode::KeyX),
185            toggle_y: Some(KeyCode::KeyY),
186            toggle_z: Some(KeyCode::KeyZ),
187            deactivate_gizmo: Some(KeyCode::Escape),
188            mouse_click_deactivates: true,
189        }
190    }
191}
192
193/// Marks an entity as a gizmo target.
194///
195/// When an entity has this component and a [`Transform`],
196/// a gizmo is shown, which can be used to manipulate the
197/// transform component.
198///
199/// If target grouping is enabled in [`GizmoOptions`],
200/// a single gizmo is used for all targets. Otherwise
201/// a separate gizmo is used for each target entity.
202#[derive(Component, Copy, Clone, Debug, Default)]
203pub struct GizmoTarget {
204    /// Whether any part of the gizmo is currently focused.
205    pub(crate) is_focused: bool,
206
207    /// Whether the gizmo is currently being interacted with.
208    pub(crate) is_active: bool,
209
210    /// This gets replaced with the result of the most recent
211    /// gizmo interaction that affected this entity.
212    pub(crate) latest_result: Option<GizmoResult>,
213}
214
215impl GizmoTarget {
216    /// Whether any part of the gizmo is currently focused.
217    pub fn is_focused(&self) -> bool {
218        self.is_focused
219    }
220
221    /// Whether the gizmo is currently being interacted with.
222    pub fn is_active(&self) -> bool {
223        self.is_active
224    }
225
226    /// This gets replaced with the result of the most recent
227    /// gizmo interaction that affected this entity.
228    pub fn latest_result(&self) -> Option<GizmoResult> {
229        self.latest_result
230    }
231}
232
233/// Marker used to specify which camera to use for gizmos.
234#[derive(Component)]
235pub struct GizmoCamera;
236
237#[derive(Resource, Default)]
238struct GizmoStorage {
239    target_entities: Vec<Entity>,
240    entity_gizmo_map: HashMap<Entity, Uuid>,
241    gizmos: HashMap<Uuid, Gizmo>,
242}
243
244fn handle_hotkeys(
245    mut gizmo_options: ResMut<GizmoOptions>,
246    keyboard_input: Res<ButtonInput<KeyCode>>,
247    mouse_input: Res<ButtonInput<MouseButton>>,
248    mut axes: Local<EnumSet<GizmoDirection>>,
249) {
250    let Some(hotkeys) = gizmo_options.hotkeys else {
251        // Hotkeys are disabled.
252        return;
253    };
254
255    if let Some(snapping_key) = hotkeys.enable_snapping {
256        gizmo_options.snapping = keyboard_input.pressed(snapping_key);
257    }
258
259    if let Some(accurate_mode_key) = hotkeys.enable_accurate_mode {
260        gizmo_options.accurate_mode = keyboard_input.pressed(accurate_mode_key);
261    }
262
263    // Modifier for inverting the mode axis selection.
264    // For example, X would force X axis, but Shift-X would force Y and Z axes.
265    let invert_modifier = keyboard_input.pressed(KeyCode::ShiftLeft);
266
267    let x_hotkey_pressed = hotkeys
268        .toggle_x
269        .is_some_and(|key| keyboard_input.just_pressed(key));
270
271    let y_hotkey_pressed = hotkeys
272        .toggle_y
273        .is_some_and(|key| keyboard_input.just_pressed(key));
274
275    let z_hotkey_pressed = hotkeys
276        .toggle_z
277        .is_some_and(|key| keyboard_input.just_pressed(key));
278
279    let mut new_axes = EnumSet::empty();
280
281    if x_hotkey_pressed {
282        new_axes = if invert_modifier {
283            enum_set!(GizmoDirection::Y | GizmoDirection::Z)
284        } else {
285            enum_set!(GizmoDirection::X)
286        };
287    };
288
289    if y_hotkey_pressed {
290        new_axes = if !invert_modifier {
291            enum_set!(GizmoDirection::Y)
292        } else {
293            enum_set!(GizmoDirection::X | GizmoDirection::Z)
294        };
295    };
296
297    if z_hotkey_pressed {
298        new_axes = if !invert_modifier {
299            enum_set!(GizmoDirection::Z)
300        } else {
301            enum_set!(GizmoDirection::X | GizmoDirection::Y)
302        };
303    };
304
305    // Replace the previously chosen axes, if any
306    if !new_axes.is_empty() {
307        if *axes == new_axes {
308            axes.clear();
309        } else {
310            *axes = new_axes;
311        }
312    }
313
314    // If we do not have any mode overridden at this point, do not force the axes either.
315    // This means you will have to first choose the mode and only then choose the axes.
316    if gizmo_options.mode_override.is_none() {
317        axes.clear();
318    }
319
320    let rotate_hotkey_pressed = hotkeys
321        .toggle_rotate
322        .is_some_and(|key| keyboard_input.just_pressed(key));
323    let translate_hotkey_pressed = hotkeys
324        .toggle_translate
325        .is_some_and(|key| keyboard_input.just_pressed(key));
326    let scale_hotkey_pressed = hotkeys
327        .toggle_scale
328        .is_some_and(|key| keyboard_input.just_pressed(key));
329
330    // Determine which mode we should switch to based on what is currently chosen
331    // and which hotkey we just pressed, if any.
332    let mode_kind = if rotate_hotkey_pressed {
333        // Rotation hotkey toggles between arcball and normal rotation
334        if gizmo_options
335            .mode_override
336            .filter(GizmoMode::is_rotate)
337            .is_some()
338        {
339            Some(GizmoModeKind::Arcball)
340        } else {
341            Some(GizmoModeKind::Rotate)
342        }
343    } else if translate_hotkey_pressed {
344        Some(GizmoModeKind::Translate)
345    } else if scale_hotkey_pressed {
346        Some(GizmoModeKind::Scale)
347    } else {
348        gizmo_options.mode_override.map(|mode| mode.kind())
349    };
350
351    if let Some(kind) = mode_kind {
352        gizmo_options.mode_override = GizmoMode::from_kind_and_axes(kind, *axes)
353            .filter(|mode| gizmo_options.gizmo_modes.contains(*mode))
354            .or_else(|| {
355                GizmoMode::all_from_kind(kind)
356                    .iter()
357                    .find(|mode| gizmo_options.gizmo_modes.contains(*mode))
358            });
359    } else {
360        gizmo_options.mode_override = None;
361    }
362
363    // Check if gizmo should be deactivated
364    if (hotkeys.mouse_click_deactivates
365        && mouse_input.any_just_pressed([MouseButton::Left, MouseButton::Right]))
366        || hotkeys
367            .deactivate_gizmo
368            .is_some_and(|key| keyboard_input.just_pressed(key))
369    {
370        gizmo_options.mode_override = None;
371    }
372}
373
374#[derive(Debug, Event, Default)]
375pub struct GizmoDragStarted;
376#[derive(Debug, Event, Default)]
377pub struct GizmoDragging;
378
379#[allow(clippy::too_many_arguments)]
380fn update_gizmos(
381    q_window: Query<&Window, With<PrimaryWindow>>,
382    q_gizmo_camera: Query<(&Camera, &GlobalTransform), With<GizmoCamera>>,
383    mut q_targets: Query<(Entity, &mut Transform, &mut GizmoTarget), Without<GizmoCamera>>,
384    mut drag_started: EventReader<GizmoDragStarted>,
385    mut dragging: EventReader<GizmoDragging>,
386    gizmo_options: Res<GizmoOptions>,
387    mut gizmo_storage: ResMut<GizmoStorage>,
388    mut last_cursor_pos: Local<Vec2>,
389    mut last_scaled_cursor_pos: Local<Vec2>,
390    #[cfg(feature = "gizmo_picking_backend")] hover_map: Res<HoverMap>,
391) {
392    let Ok(window) = q_window.single() else {
393        // No primary window found.
394        return;
395    };
396
397    let mut cursor_pos = window.cursor_position().unwrap_or_else(|| *last_cursor_pos);
398    *last_cursor_pos = cursor_pos;
399
400    let scale_factor = window.scale_factor();
401
402    let (camera, camera_transform) = {
403        let mut active_camera = None;
404
405        for camera in q_gizmo_camera.iter() {
406            if !camera.0.is_active {
407                continue;
408            }
409            if active_camera.is_some() {
410                // multiple active cameras found, warn and skip
411                bevy_log::warn!("Only one camera with a GizmoCamera component is supported.");
412                return;
413            }
414            active_camera = Some(camera);
415        }
416
417        match active_camera {
418            Some(camera) => camera,
419            None => return, // no active cameras in the scene
420        }
421    };
422
423    let Some(viewport) = camera.logical_viewport_rect() else {
424        return;
425    };
426
427    // scale up the cursor pos from the custom viewport rect, if provided
428    if let Some(custom_viewport) = gizmo_options.viewport_rect {
429        let vp_ratio = viewport.size() / custom_viewport.size();
430        let mut scaled_cursor_pos = (cursor_pos - (custom_viewport.min - viewport.min)) * vp_ratio;
431        if !viewport.contains(scaled_cursor_pos) {
432            scaled_cursor_pos = *last_scaled_cursor_pos;
433        }
434        *last_scaled_cursor_pos = scaled_cursor_pos;
435        cursor_pos = scaled_cursor_pos;
436    };
437
438    let viewport = Rect::from_min_max(
439        Pos2::new(viewport.min.x, viewport.min.y),
440        Pos2::new(viewport.max.x, viewport.max.y),
441    );
442
443    let projection_matrix = camera.clip_from_view();
444
445    let view_matrix = camera_transform.compute_matrix().inverse();
446
447    let mut snap_angle = gizmo_options.snap_angle;
448    let mut snap_distance = gizmo_options.snap_distance;
449    let mut snap_scale = gizmo_options.snap_scale;
450
451    if gizmo_options.accurate_mode {
452        snap_angle /= 2.0;
453        snap_distance /= 2.0;
454        snap_scale /= 2.0;
455    }
456
457    let gizmo_config = GizmoConfig {
458        view_matrix: view_matrix.as_dmat4().into(),
459        projection_matrix: projection_matrix.as_dmat4().into(),
460        viewport,
461        modes: gizmo_options.gizmo_modes,
462        mode_override: gizmo_options.mode_override,
463        orientation: gizmo_options.gizmo_orientation,
464        pivot_point: gizmo_options.pivot_point,
465        visuals: gizmo_options.visuals,
466        snapping: gizmo_options.snapping,
467        snap_angle,
468        snap_distance,
469        snap_scale,
470        pixels_per_point: scale_factor,
471    };
472
473    #[cfg(feature = "gizmo_picking_backend")]
474    // The gizmo picking backend sends hits to the entity the gizmo is targeting.
475    // We check for those entities in the hover map to.
476    let any_gizmo_hovered = q_targets
477        .iter()
478        .any(|(entity, ..)| hover_map.iter().any(|(_, map)| map.contains_key(&entity)));
479    #[cfg(not(feature = "gizmo_picking_backend"))]
480    let any_gizmo_hovered = true;
481
482    let hovered = any_gizmo_hovered || gizmo_options.mode_override.is_some();
483
484    let gizmo_interaction = GizmoInteraction {
485        cursor_pos: (cursor_pos.x, cursor_pos.y),
486        hovered,
487        drag_started: drag_started.read().len() > 0,
488        dragging: dragging.read().len() > 0,
489    };
490
491    let mut target_entities: Vec<Entity> = vec![];
492    let mut target_transforms: Vec<Transform> = vec![];
493
494    for (entity, mut target_transform, mut gizmo_target) in &mut q_targets {
495        target_entities.push(entity);
496        target_transforms.push(*target_transform);
497
498        if gizmo_options.group_targets {
499            gizmo_storage
500                .entity_gizmo_map
501                .insert(entity, GIZMO_GROUP_UUID);
502            continue;
503        }
504
505        let mut gizmo_uuid = *gizmo_storage
506            .entity_gizmo_map
507            .entry(entity)
508            .or_insert_with(Uuid::new_v4);
509
510        // Group gizmo was used previously
511        if gizmo_uuid == GIZMO_GROUP_UUID {
512            gizmo_uuid = Uuid::new_v4();
513            gizmo_storage.entity_gizmo_map.insert(entity, gizmo_uuid);
514        }
515
516        let gizmo = gizmo_storage.gizmos.entry(gizmo_uuid).or_default();
517        gizmo.update_config(gizmo_config);
518
519        let gizmo_result = gizmo.update(
520            gizmo_interaction,
521            &[math::Transform {
522                translation: target_transform.translation.as_dvec3().into(),
523                rotation: target_transform.rotation.as_dquat().into(),
524                scale: target_transform.scale.as_dvec3().into(),
525            }],
526        );
527
528        let is_focused = gizmo.is_focused();
529
530        gizmo_target.is_active = gizmo_result.is_some();
531        gizmo_target.is_focused = is_focused;
532
533        if let Some((_, updated_targets)) = &gizmo_result {
534            let Some(result_transform) = updated_targets.first() else {
535                bevy_log::warn!("No transform found in GizmoResult!");
536                continue;
537            };
538
539            target_transform.translation = DVec3::from(result_transform.translation).as_vec3();
540            target_transform.rotation = DQuat::from(result_transform.rotation).as_quat();
541            target_transform.scale = DVec3::from(result_transform.scale).as_vec3();
542        }
543
544        gizmo_target.latest_result = gizmo_result.map(|(result, _)| result);
545    }
546
547    if gizmo_options.group_targets {
548        let gizmo = gizmo_storage.gizmos.entry(GIZMO_GROUP_UUID).or_default();
549        gizmo.update_config(gizmo_config);
550
551        let gizmo_result = gizmo.update(
552            gizmo_interaction,
553            target_transforms
554                .iter()
555                .map(|transform| transform_gizmo::math::Transform {
556                    translation: transform.translation.as_dvec3().into(),
557                    rotation: transform.rotation.as_dquat().into(),
558                    scale: transform.scale.as_dvec3().into(),
559                })
560                .collect::<Vec<_>>()
561                .as_slice(),
562        );
563
564        let is_focused = gizmo.is_focused();
565
566        for (i, (_, mut target_transform, mut gizmo_target)) in q_targets.iter_mut().enumerate() {
567            gizmo_target.is_active = gizmo_result.is_some();
568            gizmo_target.is_focused = is_focused;
569
570            if let Some((_, updated_targets)) = &gizmo_result {
571                let Some(result_transform) = updated_targets.get(i) else {
572                    bevy_log::warn!("No transform {i} found in GizmoResult!");
573                    continue;
574                };
575
576                target_transform.translation = DVec3::from(result_transform.translation).as_vec3();
577                target_transform.rotation = DQuat::from(result_transform.rotation).as_quat();
578                target_transform.scale = DVec3::from(result_transform.scale).as_vec3();
579            }
580
581            gizmo_target.latest_result = gizmo_result.as_ref().map(|(result, _)| *result);
582        }
583    }
584
585    gizmo_storage.target_entities = target_entities;
586}
587
588fn draw_gizmos(
589    gizmo_storage: Res<GizmoStorage>,
590    mut draw_data_assets: ResMut<Assets<render::GizmoDrawData>>,
591    mut draw_data_handles: ResMut<DrawDataHandles>,
592) {
593    for (gizmo_uuid, gizmo) in &gizmo_storage.gizmos {
594        let draw_data = gizmo.draw();
595
596        let mut bevy_draw_data = render::GizmoDrawData::default();
597
598        let (asset, is_new_asset) = if let Some(handle) = draw_data_handles.handles.get(gizmo_uuid)
599        {
600            (draw_data_assets.get_mut(handle).unwrap(), false)
601        } else {
602            (&mut bevy_draw_data, true)
603        };
604
605        let viewport = &gizmo.config().viewport;
606
607        asset.0.vertices.clear();
608        asset
609            .0
610            .vertices
611            .extend(draw_data.vertices.into_iter().map(|vert| {
612                [
613                    ((vert[0] - viewport.left()) / viewport.width()) * 2.0 - 1.0,
614                    ((vert[1] - viewport.top()) / viewport.height()) * 2.0 - 1.0,
615                ]
616            }));
617
618        asset.0.colors = draw_data.colors;
619        asset.0.indices = draw_data.indices;
620
621        if is_new_asset {
622            let asset = draw_data_assets.add(bevy_draw_data);
623
624            draw_data_handles
625                .handles
626                .insert(*gizmo_uuid, asset.clone().into());
627        }
628    }
629}
630
631fn cleanup_old_data(
632    gizmo_options: Res<GizmoOptions>,
633    mut gizmo_storage: ResMut<GizmoStorage>,
634    mut draw_data_handles: ResMut<DrawDataHandles>,
635) {
636    let target_entities = std::mem::take(&mut gizmo_storage.target_entities);
637
638    let mut gizmos_to_keep = vec![];
639
640    if gizmo_options.group_targets && !target_entities.is_empty() {
641        gizmos_to_keep.push(GIZMO_GROUP_UUID);
642    }
643
644    gizmo_storage.entity_gizmo_map.retain(|entity, uuid| {
645        if !target_entities.contains(entity) {
646            false
647        } else {
648            gizmos_to_keep.push(*uuid);
649
650            true
651        }
652    });
653
654    gizmo_storage
655        .gizmos
656        .retain(|uuid, _| gizmos_to_keep.contains(uuid));
657
658    draw_data_handles
659        .handles
660        .retain(|uuid, _| gizmos_to_keep.contains(uuid));
661}