Skip to main content

bevy_react/layer/
pick3d.rs

1//! Transformed picking for `transform3d` layers.
2//!
3//! Layer promotion is render-side only, so a 3D-transformed subtree's nodes
4//! keep their untransformed layout rects on the window camera — the stock
5//! hit-test sees them where layout put them, not where the composite quad
6//! draws them. This module makes picking follow the visual:
7//!
8//! 1. [`drive_transform3d_pointer`] inverts the cursor through the topmost
9//!    transformed layer's screen homography onto the layer plane and drives a
10//!    shared `PointerId::Custom` virtual pointer at the recovered
11//!    *untransformed* window position — stock picking then resolves the
12//!    subtree's descendants normally (the `<surface>` virtual-pointer
13//!    pattern, but window-target).
14//! 2. [`suppress_transformed_layer_hits`] drops the real mouse pointer's hits
15//!    on members of visually-transformed layers (their layout rects are
16//!    visually stale) and scopes the virtual pointer's hits to the layer it
17//!    is remapping into (its window location overlaps unrelated nodes).
18//! 3. [`correct_transformed_interactions`] re-derives the legacy
19//!    `Interaction` for those members from the virtual pointer's `HoverMap`
20//!    entry — `ui_focus_system` runs its own geometric hit-test that neither
21//!    1 nor 2 reaches.
22//!
23//! Identity-valued transforms keep everything here inert (visual == layout);
24//! nested transformed-in-transformed layers invert the *nearest* transformed
25//! root only — a documented v1 limitation. Click-through *under* the visual
26//! quad (the real pointer hitting non-members the transformed result covers)
27//! is out of scope for v1.
28
29use bevy::ecs::message::MessageMutator;
30use bevy::input::ButtonInput;
31use bevy::input::mouse::MouseButton;
32use bevy::picking::backend::PointerHits;
33use bevy::picking::hover::HoverMap;
34use bevy::picking::pointer::{
35    Location, PointerAction, PointerButton, PointerId, PointerInput, PointerLocation, PointerPress,
36};
37use bevy::prelude::*;
38use bevy::ui::{ComputedNode, UiGlobalTransform, UiStack};
39use bevy::window::PrimaryWindow;
40
41use super::transform3d::LayerTransform3dMatrix;
42use super::{LayerMembership, PromotedLayer};
43
44/// The transform3d virtual pointer's fixed id (see
45/// [`crate::surface::SURFACE_POINTER_UUID`] for the pattern).
46pub const TRANSFORM3D_POINTER_UUID: uuid::Uuid = uuid::Uuid::from_u128(0x7D3D_D001);
47
48/// The mouse buttons forwarded to the virtual pointer — the same set the
49/// surface pointer forwards.
50const FORWARDED_BUTTONS: [(MouseButton, PointerButton); 3] = [
51    (MouseButton::Left, PointerButton::Primary),
52    (MouseButton::Right, PointerButton::Secondary),
53    (MouseButton::Middle, PointerButton::Middle),
54];
55
56/// Index of a forwarded button in [`Transform3dPointer::pressed`].
57fn button_index(button: PointerButton) -> usize {
58    match button {
59        PointerButton::Primary => 0,
60        PointerButton::Secondary => 1,
61        PointerButton::Middle => 2,
62    }
63}
64
65/// The single virtual pointer remapping cursor input into transformed layers
66/// (topmost-wins, like the one surface pointer serving every surface), plus
67/// its frame-to-frame state.
68#[derive(Resource)]
69pub struct Transform3dPointer {
70    /// The custom pointer id. Picking events carrying this id originated from
71    /// a transformed-layer remap.
72    pub id: PointerId,
73    /// The transformed layer root the pointer is currently remapped into —
74    /// the scope [`suppress_transformed_layer_hits`] retains virtual-pointer
75    /// hits to. `None` = parked.
76    pub over_layer: Option<Entity>,
77    /// Last driven position (logical window coords).
78    last_pos: Vec2,
79    /// The window target last driven to (kept for the park move once the
80    /// cursor — and with it the mouse pointer's live location — leaves).
81    last_target: Option<bevy::camera::NormalizedRenderTarget>,
82    /// Per-button owed-release flags, indexed by [`button_index`].
83    pressed: [bool; FORWARDED_BUTTONS.len()],
84}
85
86/// Spawn the virtual pointer at startup and publish its id.
87pub fn init_transform3d_pointer(mut commands: Commands) {
88    let id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
89    // Spawning a `PointerId` auto-adds `PointerLocation`/`PointerPress`/….
90    commands.spawn(id);
91    commands.insert_resource(Transform3dPointer {
92        id,
93        over_layer: None,
94        last_pos: Vec2::ZERO,
95        last_target: None,
96        pressed: [false; FORWARDED_BUTTONS.len()],
97    });
98}
99
100/// The 3×3 screen homography of a layer's composite matrix: the model maps
101/// plane points `(x, y, 0, 1)` to homogeneous screen `(X, Y, ·, W)`, so only
102/// the x/y/w rows of the x/y/w columns matter (z is flattened at composite).
103fn screen_homography(m: &Mat4) -> Mat3 {
104    Mat3::from_cols(
105        Vec3::new(m.x_axis.x, m.x_axis.y, m.x_axis.w),
106        Vec3::new(m.y_axis.x, m.y_axis.y, m.y_axis.w),
107        Vec3::new(m.w_axis.x, m.w_axis.y, m.w_axis.w),
108    )
109}
110
111/// Map a physical-px screen position back onto the layer plane (untransformed
112/// physical px). `None` when the plane is edge-on (degenerate homography),
113/// the recovered point sits at infinity, or it lies behind the eye (past the
114/// vanishing line, where the quad is not visible). A backface (negative
115/// determinant) still inverts — backfaces render and stay clickable.
116pub fn invert_screen_to_plane(model: &Mat4, screen: Vec2) -> Option<Vec2> {
117    let h = screen_homography(model);
118    if h.determinant().abs() < 1e-6 {
119        return None; // Edge-on: the quad projects to a line.
120    }
121    let p = h.inverse() * screen.extend(1.0);
122    if p.z.abs() < 1e-6 {
123        return None; // Point at infinity on the plane.
124    }
125    let local = p.truncate() / p.z;
126    // Forward w at the recovered point must be positive: a screen point can
127    // invert onto the plane's far side (behind the eye), which never renders.
128    // w = (H · (x, y, 1)).z — the homography's third row.
129    let w = h.x_axis.z * local.x + h.y_axis.z * local.y + h.z_axis.z;
130    if w <= 0.0 {
131        return None;
132    }
133    Some(local)
134}
135
136/// The nearest visually-transformed (non-identity matrix) layer root in
137/// `entity`'s enclosing-or-self layer chain, if any. Climbs `ChildOf` first —
138/// a picked leaf may be a text span outside the membership map.
139fn transformed_root_of(
140    entity: Entity,
141    membership: &LayerMembership,
142    matrices: &Query<&LayerTransform3dMatrix>,
143    child_of: &Query<&ChildOf>,
144) -> Option<Entity> {
145    let member = crate::reconcile::climb(entity, child_of, |e| {
146        membership.node_to_layer.contains_key(&e)
147    })?;
148    let mut root = *membership.node_to_layer.get(&member)?;
149    loop {
150        if matrices.get(root).is_ok_and(|m| !m.identity) {
151            return Some(root);
152        }
153        root = (*membership.enclosing.get(&root)?)?;
154    }
155}
156
157/// Whether `entity`'s enclosing-or-self layer chain passes through `layer`.
158fn member_of_layer(
159    entity: Entity,
160    layer: Entity,
161    membership: &LayerMembership,
162    child_of: &Query<&ChildOf>,
163) -> bool {
164    let Some(member) = crate::reconcile::climb(entity, child_of, |e| {
165        membership.node_to_layer.contains_key(&e)
166    }) else {
167        return false;
168    };
169    let mut root = match membership.node_to_layer.get(&member) {
170        Some(&root) => root,
171        None => return false,
172    };
173    loop {
174        if root == layer {
175            return true;
176        }
177        match membership.enclosing.get(&root) {
178            Some(Some(outer)) => root = *outer,
179            _ => return false,
180        }
181    }
182}
183
184/// Remap the window cursor into the topmost visually-transformed layer under
185/// it and drive the virtual pointer there (`PointerInput` move/press/release,
186/// the surface driver's contract). With no transformed layer under the
187/// cursor — or no transformed layers at all — the pointer parks off-bounds
188/// and releases owed presses.
189///
190/// Scheduled before `bevy_picking`'s input processing so the new location is
191/// consumed the same frame.
192#[allow(clippy::too_many_arguments, clippy::type_complexity)]
193pub fn drive_transform3d_pointer(
194    mut state: ResMut<Transform3dPointer>,
195    layers: Query<
196        (
197            Entity,
198            &ComputedNode,
199            &UiGlobalTransform,
200            &LayerTransform3dMatrix,
201        ),
202        With<PromotedLayer>,
203    >,
204    ui_stack: Res<UiStack>,
205    pointers: Query<(&PointerId, &PointerLocation)>,
206    windows: Query<&Window, With<PrimaryWindow>>,
207    buttons: Res<ButtonInput<MouseButton>>,
208    mut input: MessageWriter<PointerInput>,
209) {
210    let pointer_id = state.id;
211    // The real mouse pointer's live location carries exactly the window
212    // target picking expects; its absence (cursor off-window) parks us.
213    let mouse = pointers
214        .iter()
215        .find(|(id, _)| matches!(id, PointerId::Mouse))
216        .and_then(|(_, loc)| loc.location().cloned());
217    let scale = windows.single().map(|w| w.scale_factor()).unwrap_or(1.0);
218
219    let candidate = mouse.as_ref().and_then(|loc| {
220        let cursor = loc.position * scale;
221        // Topmost-first: the highest UiStack (paint-order) index wins among
222        // transformed layers whose inverted cursor lands in their border box.
223        let mut best: Option<(usize, Entity, Vec2)> = None;
224        for (root, computed, transform, matrix) in &layers {
225            if matrix.identity {
226                continue;
227            }
228            let size = computed.size();
229            if size.x <= 0.5 || size.y <= 0.5 {
230                continue;
231            }
232            let Some(local) = invert_screen_to_plane(&matrix.model, cursor) else {
233                continue;
234            };
235            let min = transform.translation - size * 0.5;
236            if local.x < min.x
237                || local.y < min.y
238                || local.x > min.x + size.x
239                || local.y > min.y + size.y
240            {
241                continue;
242            }
243            let index = ui_stack.uinodes.iter().position(|&e| e == root);
244            let index = index.unwrap_or(0);
245            if best.is_none_or(|(top, _, _)| index > top) {
246                best = Some((index, root, local));
247            }
248        }
249        best.map(|(_, root, local)| (root, local, loc.target.clone()))
250    });
251
252    if let Some((root, local, target)) = candidate {
253        let position = local / scale; // physical → logical window coords
254        let location = Location {
255            target: target.clone(),
256            position,
257        };
258        let delta = position - state.last_pos;
259        // A zero-delta move carries no information — unless we just remapped
260        // into a (different) layer, where the move is what retargets picking.
261        if delta != Vec2::ZERO || state.over_layer != Some(root) {
262            input.write(PointerInput::new(
263                pointer_id,
264                location.clone(),
265                PointerAction::Move { delta },
266            ));
267        }
268        state.last_pos = position;
269        state.last_target = Some(target);
270        state.over_layer = Some(root);
271
272        for (mb, pb) in FORWARDED_BUTTONS {
273            if buttons.just_pressed(mb) {
274                input.write(PointerInput::new(
275                    pointer_id,
276                    location.clone(),
277                    PointerAction::Press(pb),
278                ));
279                state.pressed[button_index(pb)] = true;
280            }
281            if buttons.just_released(mb) && state.pressed[button_index(pb)] {
282                input.write(PointerInput::new(
283                    pointer_id,
284                    location.clone(),
285                    PointerAction::Release(pb),
286                ));
287                state.pressed[button_index(pb)] = false;
288            }
289        }
290        return;
291    }
292
293    // Parked: release owed presses and move off-bounds once, so picking fires
294    // `Out` and no control sticks (the surface driver's leave contract).
295    if state.over_layer.is_some()
296        && let Some(target) = state.last_target.clone()
297    {
298        let location = Location {
299            target,
300            position: Vec2::splat(-1.0),
301        };
302        for (_, pb) in FORWARDED_BUTTONS {
303            if state.pressed[button_index(pb)] {
304                input.write(PointerInput::new(
305                    pointer_id,
306                    location.clone(),
307                    PointerAction::Release(pb),
308                ));
309                state.pressed[button_index(pb)] = false;
310            }
311        }
312        input.write(PointerInput::new(
313            pointer_id,
314            location,
315            PointerAction::Move { delta: Vec2::ZERO },
316        ));
317        state.over_layer = None;
318        state.last_pos = Vec2::splat(-1.0);
319    }
320}
321
322/// Scope picking hits around the transformed-layer remap: the real mouse
323/// pointer must not hit members of visually-transformed layers (their layout
324/// rects are stale), and the virtual pointer must hit *only* members of the
325/// layer it is remapping into (its window location overlaps whatever else
326/// layout put there). Runs between the picking backends and the hover-map
327/// update, after the clip filter.
328pub fn suppress_transformed_layer_hits(
329    mut hits: MessageMutator<PointerHits>,
330    state: Option<Res<Transform3dPointer>>,
331    membership: Res<LayerMembership>,
332    matrices: Query<&LayerTransform3dMatrix>,
333    child_of: Query<&ChildOf>,
334) {
335    let Some(state) = state else {
336        return;
337    };
338    for hits in hits.read() {
339        if hits.pointer == state.id {
340            match state.over_layer {
341                Some(layer) => hits
342                    .picks
343                    .retain(|(entity, _)| member_of_layer(*entity, layer, &membership, &child_of)),
344                // Parked off-bounds: nothing it reports is meaningful.
345                None => hits.picks.clear(),
346            }
347        } else if matches!(hits.pointer, PointerId::Mouse) {
348            hits.picks.retain(|(entity, _)| {
349                transformed_root_of(*entity, &membership, &matrices, &child_of).is_none()
350            });
351        }
352    }
353}
354
355/// Re-derive the legacy `Interaction` — and `RelativeCursorPosition` — for
356/// members of visually-transformed layers from the virtual pointer
357/// (`HoverMap` entry + press state + remapped location). `ui_focus_system`
358/// computes both geometrically from the *real* cursor against the stale
359/// layout rect ([`suppress_transformed_layer_hits`] never reaches it), so
360/// without this the real pointer lights hover/press styling — and reports
361/// drag positions — where layout put the subtree, not where it renders.
362/// Runs before `apply_interaction_styles`/`collect_hover_events`/
363/// `collect_pointer_events` so styling, enter/leave, and `onPointer*`
364/// positions all see the corrected state.
365#[allow(clippy::type_complexity, clippy::too_many_arguments)]
366pub fn correct_transformed_interactions(
367    state: Option<Res<Transform3dPointer>>,
368    hover_map: Option<Res<HoverMap>>,
369    pointers: Query<(&PointerId, &PointerPress, &PointerLocation)>,
370    windows: Query<&Window, With<PrimaryWindow>>,
371    membership: Res<LayerMembership>,
372    matrices: Query<&LayerTransform3dMatrix>,
373    child_of: Query<&ChildOf>,
374    mut interactions: Query<(
375        Entity,
376        &mut Interaction,
377        Option<&mut bevy::ui::RelativeCursorPosition>,
378        Option<&ComputedNode>,
379        Option<&UiGlobalTransform>,
380    )>,
381) {
382    let Some(state) = state else {
383        return;
384    };
385    // Fast path: no visually-transformed layer this frame → nothing to fix.
386    if !matrices.iter().any(|m| !m.identity) {
387        return;
388    }
389    let hovered = hover_map.as_ref().and_then(|map| map.get(&state.id));
390    let virtual_pointer = pointers.iter().find(|(id, _, _)| **id == state.id);
391    let pressed = virtual_pointer.is_some_and(|(_, press, _)| press.is_primary_pressed());
392    // The remapped position in physical px (the space of node geometry) —
393    // only meaningful while the pointer is remapped into a layer.
394    let scale = windows.single().map(|w| w.scale_factor()).unwrap_or(1.0);
395    let remapped_physical = state.over_layer.and_then(|_| {
396        virtual_pointer
397            .and_then(|(_, _, loc)| loc.location())
398            .map(|loc| loc.position * scale)
399    });
400    for (entity, mut interaction, rel, computed, transform) in &mut interactions {
401        if transformed_root_of(entity, &membership, &matrices, &child_of).is_none() {
402            continue;
403        }
404        let over = hovered.is_some_and(|map| map.contains_key(&entity));
405        let desired = if over {
406            if pressed {
407                Interaction::Pressed
408            } else {
409                Interaction::Hovered
410            }
411        } else {
412            Interaction::None
413        };
414        interaction.set_if_neq(desired);
415        // Remap the relative cursor from the virtual pointer's untransformed
416        // position (centered convention, like `ui_focus_system`'s own write).
417        if let Some(mut rel) = rel {
418            let normalized = remapped_physical.and_then(|pos| {
419                computed
420                    .zip(transform)
421                    .and_then(|(c, t)| c.normalize_point(*t, pos))
422            });
423            let next = bevy::ui::RelativeCursorPosition {
424                cursor_over: over,
425                normalized,
426            };
427            if rel.cursor_over != next.cursor_over || rel.normalized != next.normalized {
428                *rel = next;
429            }
430        }
431    }
432}
433
434/// The set of entities that belong to a visually-transformed layer this
435/// frame — the members the window cursor scan must skip (their layout rects
436/// are stale). Empty when no layer is transformed.
437pub fn visually_transformed_members(
438    membership: &LayerMembership,
439    matrices: &Query<&LayerTransform3dMatrix>,
440) -> bevy::platform::collections::HashSet<Entity> {
441    let mut transformed_roots: Vec<Entity> = Vec::new();
442    for (&root, _) in membership.enclosing.iter() {
443        if matrices.get(root).is_ok_and(|m| !m.identity) {
444            transformed_roots.push(root);
445        }
446    }
447    if transformed_roots.is_empty() {
448        return Default::default();
449    }
450    membership
451        .node_to_layer
452        .iter()
453        .filter(|(_, own_root)| {
454            // Walk the enclosing chain from the member's own root.
455            let mut root = **own_root;
456            loop {
457                if transformed_roots.contains(&root) {
458                    return true;
459                }
460                match membership.enclosing.get(&root) {
461                    Some(Some(outer)) => root = *outer,
462                    _ => return false,
463                }
464            }
465        })
466        .map(|(&node, _)| node)
467        .collect()
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::protocol::{Transform3d, Transform3dOrigin};
474
475    fn deg(v: f32) -> Option<crate::protocol::Animatable<crate::protocol::Angle>> {
476        Some(crate::protocol::Animatable::Static(
477            crate::protocol::Angle::from_radians(v.to_radians()),
478        ))
479    }
480
481    /// Static-wrap a scalar channel value.
482    fn st(v: f32) -> Option<crate::protocol::Animatable<f32>> {
483        Some(crate::protocol::Animatable::Static(v))
484    }
485
486    /// Static-wrap an origin axis.
487    fn ax(l: crate::protocol::Length) -> crate::protocol::Animatable<crate::protocol::Length> {
488        crate::protocol::Animatable::Static(l)
489    }
490
491    /// Forward-project plane points through a perspective matrix, invert the
492    /// screen position, and recover the original plane point.
493    #[test]
494    fn homography_inversion_round_trips() {
495        let params = Transform3d {
496            perspective: st(600.0),
497            rotate_y: deg(35.0),
498            rotate_x: deg(-12.0),
499            translate_x: st(30.0),
500            scale: st(1.2),
501            origin: Some(Transform3dOrigin {
502                x: ax(crate::protocol::Length::Percent(25.0)),
503                y: ax(crate::protocol::Length::Percent(50.0)),
504            }),
505            ..Default::default()
506        };
507        let min = Vec2::new(300.0, 200.0);
508        let size = Vec2::new(240.0, 160.0);
509        let m = super::super::transform3d::build_transform3d_matrix(&params, min, size, 1.0);
510        for local in [
511            min,
512            min + size,
513            min + size * 0.5,
514            min + Vec2::new(10.0, 100.0),
515        ] {
516            let screen = m.project_point3(local.extend(0.0)).truncate();
517            let back = invert_screen_to_plane(&m, screen).expect("invertible");
518            assert!(back.abs_diff_eq(local, 1e-2), "{local} → {screen} → {back}");
519        }
520    }
521
522    /// An edge-on plane (rotateY 90°) yields no hit; a backface (rotated past
523    /// 90°) still inverts.
524    #[test]
525    fn edge_on_misses_backface_hits() {
526        let base = Transform3d {
527            origin: Some(Transform3dOrigin {
528                x: ax(crate::protocol::Length::Percent(50.0)),
529                y: ax(crate::protocol::Length::Percent(50.0)),
530            }),
531            ..Default::default()
532        };
533        let min = Vec2::ZERO;
534        let size = Vec2::new(100.0, 100.0);
535
536        let edge_on = Transform3d {
537            rotate_y: deg(90.0),
538            ..base.clone()
539        };
540        let m = super::super::transform3d::build_transform3d_matrix(&edge_on, min, size, 1.0);
541        assert!(invert_screen_to_plane(&m, Vec2::new(50.0, 50.0)).is_none());
542
543        let backface = Transform3d {
544            rotate_y: deg(150.0),
545            perspective: st(800.0),
546            ..base
547        };
548        let m = super::super::transform3d::build_transform3d_matrix(&backface, min, size, 1.0);
549        let screen = m.project_point3(Vec3::new(30.0, 40.0, 0.0)).truncate();
550        let back = invert_screen_to_plane(&m, screen).expect("backface inverts");
551        assert!(back.abs_diff_eq(Vec2::new(30.0, 40.0), 1e-2));
552    }
553
554    /// Suppression: the mouse loses hits on transformed-layer members (but
555    /// keeps unrelated ones); the virtual pointer keeps only members of the
556    /// layer it is over, and everything when parked is dropped.
557    #[test]
558    fn suppression_scopes_hits_per_pointer() {
559        use bevy::ecs::system::RunSystemOnce;
560        use bevy::picking::backend::HitData;
561
562        let mut world = World::new();
563        world.init_resource::<Messages<PointerHits>>();
564        let camera = world.spawn_empty().id();
565
566        let transformed_root = world
567            .spawn(LayerTransform3dMatrix {
568                model: Mat4::from_rotation_y(0.5),
569                identity: false,
570            })
571            .id();
572        let member = world.spawn(ChildOf(transformed_root)).id();
573        let unrelated = world.spawn_empty().id();
574
575        let mut membership = LayerMembership::default();
576        membership
577            .node_to_layer
578            .insert(transformed_root, transformed_root);
579        membership.node_to_layer.insert(member, transformed_root);
580        membership.enclosing.insert(transformed_root, None);
581        world.insert_resource(membership);
582
583        let virtual_id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
584        world.insert_resource(Transform3dPointer {
585            id: virtual_id,
586            over_layer: Some(transformed_root),
587            last_pos: Vec2::ZERO,
588            last_target: None,
589            pressed: [false; 3],
590        });
591
592        let send = |world: &mut World, pointer: PointerId, entities: &[Entity]| {
593            let picks = entities
594                .iter()
595                .map(|&e| (e, HitData::new(camera, 0.0, None, None)))
596                .collect();
597            world
598                .resource_mut::<Messages<PointerHits>>()
599                .write(PointerHits::new(pointer, picks, 0.5));
600        };
601        let survivors = |world: &mut World| -> Vec<(PointerId, Vec<Entity>)> {
602            world
603                .resource_mut::<Messages<PointerHits>>()
604                .drain()
605                .map(|h| (h.pointer, h.picks.into_iter().map(|(e, _)| e).collect()))
606                .collect()
607        };
608
609        // Mouse: member dropped, unrelated kept.
610        send(&mut world, PointerId::Mouse, &[member, unrelated]);
611        // Virtual pointer over the layer: member kept, unrelated dropped.
612        send(&mut world, virtual_id, &[member, unrelated]);
613        world
614            .run_system_once(suppress_transformed_layer_hits)
615            .unwrap();
616        let got = survivors(&mut world);
617        assert_eq!(got[0], (PointerId::Mouse, vec![unrelated]));
618        assert_eq!(got[1], (virtual_id, vec![member]));
619
620        // Parked virtual pointer: everything dropped.
621        world.resource_mut::<Transform3dPointer>().over_layer = None;
622        send(&mut world, virtual_id, &[member, unrelated]);
623        world
624            .run_system_once(suppress_transformed_layer_hits)
625            .unwrap();
626        let got = survivors(&mut world);
627        assert_eq!(got[0].1, Vec::<Entity>::new());
628    }
629
630    /// Interaction correction: a transformed-layer member follows the virtual
631    /// pointer's hover map (Hovered / Pressed / None); untransformed nodes
632    /// are untouched.
633    #[test]
634    fn interaction_correction_follows_virtual_pointer() {
635        use bevy::ecs::entity::EntityHashMap;
636        use bevy::ecs::system::RunSystemOnce;
637        use bevy::picking::backend::HitData;
638
639        let mut world = World::new();
640        let camera = world.spawn_empty().id();
641        let root = world
642            .spawn(LayerTransform3dMatrix {
643                model: Mat4::from_rotation_y(0.5),
644                identity: false,
645            })
646            .id();
647        let member = world.spawn((ChildOf(root), Interaction::None)).id();
648        let outside = world.spawn(Interaction::Hovered).id();
649
650        let mut membership = LayerMembership::default();
651        membership.node_to_layer.insert(root, root);
652        membership.node_to_layer.insert(member, root);
653        membership.enclosing.insert(root, None);
654        world.insert_resource(membership);
655
656        let virtual_id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
657        world.spawn((virtual_id, PointerPress::default()));
658        world.insert_resource(Transform3dPointer {
659            id: virtual_id,
660            over_layer: Some(root),
661            last_pos: Vec2::ZERO,
662            last_target: None,
663            pressed: [false; 3],
664        });
665
666        // Virtual pointer hovers the member → Hovered.
667        let mut hover = HoverMap::default();
668        let mut entry: EntityHashMap<HitData> = EntityHashMap::default();
669        entry.insert(member, HitData::new(camera, 0.0, None, None));
670        hover.insert(virtual_id, entry);
671        world.insert_resource(hover);
672        world
673            .run_system_once(correct_transformed_interactions)
674            .unwrap();
675        assert_eq!(
676            *world.get::<Interaction>(member).unwrap(),
677            Interaction::Hovered
678        );
679        assert_eq!(
680            *world.get::<Interaction>(outside).unwrap(),
681            Interaction::Hovered,
682            "nodes outside transformed layers are untouched"
683        );
684
685        // Empty hover entry → the stale-rect Interaction the real pointer set
686        // geometrically is cleared.
687        if let Some(mut i) = world.get_mut::<Interaction>(member) {
688            *i = Interaction::Hovered;
689        }
690        world.insert_resource({
691            let mut hover = HoverMap::default();
692            hover.insert(virtual_id, EntityHashMap::default());
693            hover
694        });
695        world
696            .run_system_once(correct_transformed_interactions)
697            .unwrap();
698        assert_eq!(
699            *world.get::<Interaction>(member).unwrap(),
700            Interaction::None
701        );
702    }
703}