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::{transform::Transform3d, transform::Transform3dOrigin};
474
475    fn deg(
476        v: f32,
477    ) -> Option<crate::protocol::animatable::Animatable<crate::protocol::units::Angle>> {
478        Some(crate::protocol::animatable::Animatable::Static(
479            crate::protocol::units::Angle::from_radians(v.to_radians()),
480        ))
481    }
482
483    /// Static-wrap a scalar channel value.
484    fn st(v: f32) -> Option<crate::protocol::animatable::Animatable<f32>> {
485        Some(crate::protocol::animatable::Animatable::Static(v))
486    }
487
488    /// Static-wrap an origin axis.
489    fn ax(
490        l: crate::protocol::units::Length,
491    ) -> crate::protocol::animatable::Animatable<crate::protocol::units::Length> {
492        crate::protocol::animatable::Animatable::Static(l)
493    }
494
495    /// Forward-project plane points through a perspective matrix, invert the
496    /// screen position, and recover the original plane point.
497    #[test]
498    fn homography_inversion_round_trips() {
499        let params = Transform3d {
500            perspective: st(600.0),
501            rotate_y: deg(35.0),
502            rotate_x: deg(-12.0),
503            translate_x: st(30.0),
504            scale: st(1.2),
505            origin: Some(Transform3dOrigin {
506                x: ax(crate::protocol::units::Length::Percent(25.0)),
507                y: ax(crate::protocol::units::Length::Percent(50.0)),
508            }),
509            ..Default::default()
510        };
511        let min = Vec2::new(300.0, 200.0);
512        let size = Vec2::new(240.0, 160.0);
513        let m = super::super::transform3d::build_transform3d_matrix(&params, min, size, 1.0);
514        for local in [
515            min,
516            min + size,
517            min + size * 0.5,
518            min + Vec2::new(10.0, 100.0),
519        ] {
520            let screen = m.project_point3(local.extend(0.0)).truncate();
521            let back = invert_screen_to_plane(&m, screen).expect("invertible");
522            assert!(back.abs_diff_eq(local, 1e-2), "{local} → {screen} → {back}");
523        }
524    }
525
526    /// An edge-on plane (rotateY 90°) yields no hit; a backface (rotated past
527    /// 90°) still inverts.
528    #[test]
529    fn edge_on_misses_backface_hits() {
530        let base = Transform3d {
531            origin: Some(Transform3dOrigin {
532                x: ax(crate::protocol::units::Length::Percent(50.0)),
533                y: ax(crate::protocol::units::Length::Percent(50.0)),
534            }),
535            ..Default::default()
536        };
537        let min = Vec2::ZERO;
538        let size = Vec2::new(100.0, 100.0);
539
540        let edge_on = Transform3d {
541            rotate_y: deg(90.0),
542            ..base.clone()
543        };
544        let m = super::super::transform3d::build_transform3d_matrix(&edge_on, min, size, 1.0);
545        assert!(invert_screen_to_plane(&m, Vec2::new(50.0, 50.0)).is_none());
546
547        let backface = Transform3d {
548            rotate_y: deg(150.0),
549            perspective: st(800.0),
550            ..base
551        };
552        let m = super::super::transform3d::build_transform3d_matrix(&backface, min, size, 1.0);
553        let screen = m.project_point3(Vec3::new(30.0, 40.0, 0.0)).truncate();
554        let back = invert_screen_to_plane(&m, screen).expect("backface inverts");
555        assert!(back.abs_diff_eq(Vec2::new(30.0, 40.0), 1e-2));
556    }
557
558    /// Suppression: the mouse loses hits on transformed-layer members (but
559    /// keeps unrelated ones); the virtual pointer keeps only members of the
560    /// layer it is over, and everything when parked is dropped.
561    #[test]
562    fn suppression_scopes_hits_per_pointer() {
563        use bevy::ecs::system::RunSystemOnce;
564        use bevy::picking::backend::HitData;
565
566        let mut world = World::new();
567        world.init_resource::<Messages<PointerHits>>();
568        let camera = world.spawn_empty().id();
569
570        let transformed_root = world
571            .spawn(LayerTransform3dMatrix {
572                model: Mat4::from_rotation_y(0.5),
573                identity: false,
574            })
575            .id();
576        let member = world.spawn(ChildOf(transformed_root)).id();
577        let unrelated = world.spawn_empty().id();
578
579        let mut membership = LayerMembership::default();
580        membership
581            .node_to_layer
582            .insert(transformed_root, transformed_root);
583        membership.node_to_layer.insert(member, transformed_root);
584        membership.enclosing.insert(transformed_root, None);
585        world.insert_resource(membership);
586
587        let virtual_id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
588        world.insert_resource(Transform3dPointer {
589            id: virtual_id,
590            over_layer: Some(transformed_root),
591            last_pos: Vec2::ZERO,
592            last_target: None,
593            pressed: [false; 3],
594        });
595
596        let send = |world: &mut World, pointer: PointerId, entities: &[Entity]| {
597            let picks = entities
598                .iter()
599                .map(|&e| (e, HitData::new(camera, 0.0, None, None)))
600                .collect();
601            world
602                .resource_mut::<Messages<PointerHits>>()
603                .write(PointerHits::new(pointer, picks, 0.5));
604        };
605        let survivors = |world: &mut World| -> Vec<(PointerId, Vec<Entity>)> {
606            world
607                .resource_mut::<Messages<PointerHits>>()
608                .drain()
609                .map(|h| (h.pointer, h.picks.into_iter().map(|(e, _)| e).collect()))
610                .collect()
611        };
612
613        // Mouse: member dropped, unrelated kept.
614        send(&mut world, PointerId::Mouse, &[member, unrelated]);
615        // Virtual pointer over the layer: member kept, unrelated dropped.
616        send(&mut world, virtual_id, &[member, unrelated]);
617        world
618            .run_system_once(suppress_transformed_layer_hits)
619            .unwrap();
620        let got = survivors(&mut world);
621        assert_eq!(got[0], (PointerId::Mouse, vec![unrelated]));
622        assert_eq!(got[1], (virtual_id, vec![member]));
623
624        // Parked virtual pointer: everything dropped.
625        world.resource_mut::<Transform3dPointer>().over_layer = None;
626        send(&mut world, virtual_id, &[member, unrelated]);
627        world
628            .run_system_once(suppress_transformed_layer_hits)
629            .unwrap();
630        let got = survivors(&mut world);
631        assert_eq!(got[0].1, Vec::<Entity>::new());
632    }
633
634    /// Interaction correction: a transformed-layer member follows the virtual
635    /// pointer's hover map (Hovered / Pressed / None); untransformed nodes
636    /// are untouched.
637    #[test]
638    fn interaction_correction_follows_virtual_pointer() {
639        use bevy::ecs::entity::EntityHashMap;
640        use bevy::ecs::system::RunSystemOnce;
641        use bevy::picking::backend::HitData;
642
643        let mut world = World::new();
644        let camera = world.spawn_empty().id();
645        let root = world
646            .spawn(LayerTransform3dMatrix {
647                model: Mat4::from_rotation_y(0.5),
648                identity: false,
649            })
650            .id();
651        let member = world.spawn((ChildOf(root), Interaction::None)).id();
652        let outside = world.spawn(Interaction::Hovered).id();
653
654        let mut membership = LayerMembership::default();
655        membership.node_to_layer.insert(root, root);
656        membership.node_to_layer.insert(member, root);
657        membership.enclosing.insert(root, None);
658        world.insert_resource(membership);
659
660        let virtual_id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
661        world.spawn((virtual_id, PointerPress::default()));
662        world.insert_resource(Transform3dPointer {
663            id: virtual_id,
664            over_layer: Some(root),
665            last_pos: Vec2::ZERO,
666            last_target: None,
667            pressed: [false; 3],
668        });
669
670        // Virtual pointer hovers the member → Hovered.
671        let mut hover = HoverMap::default();
672        let mut entry: EntityHashMap<HitData> = EntityHashMap::default();
673        entry.insert(member, HitData::new(camera, 0.0, None, None));
674        hover.insert(virtual_id, entry);
675        world.insert_resource(hover);
676        world
677            .run_system_once(correct_transformed_interactions)
678            .unwrap();
679        assert_eq!(
680            *world.get::<Interaction>(member).unwrap(),
681            Interaction::Hovered
682        );
683        assert_eq!(
684            *world.get::<Interaction>(outside).unwrap(),
685            Interaction::Hovered,
686            "nodes outside transformed layers are untouched"
687        );
688
689        // Empty hover entry → the stale-rect Interaction the real pointer set
690        // geometrically is cleared.
691        if let Some(mut i) = world.get_mut::<Interaction>(member) {
692            *i = Interaction::Hovered;
693        }
694        world.insert_resource({
695            let mut hover = HoverMap::default();
696            hover.insert(virtual_id, EntityHashMap::default());
697            hover
698        });
699        world
700            .run_system_once(correct_transformed_interactions)
701            .unwrap();
702        assert_eq!(
703            *world.get::<Interaction>(member).unwrap(),
704            Interaction::None
705        );
706    }
707}