use bevy::camera::Camera;
use bevy::ecs::message::MessageMutator;
use bevy::picking::backend::PointerHits;
use bevy::picking::pointer::{Location, PointerId, PointerLocation};
use bevy::prelude::*;
use bevy::ui::{CalculatedClip, ComputedNode};
pub(crate) fn pointer_physical_position(location: &Location, camera: &Camera) -> Vec2 {
let mut point = location.position * camera.target_scaling_factor().unwrap_or(1.0);
if let Some(viewport) = camera.physical_viewport_rect() {
point -= viewport.min.as_vec2();
}
point
}
pub fn filter_clipped_pointer_hits(
mut hits: MessageMutator<PointerHits>,
pointers: Query<(&PointerId, &PointerLocation)>,
cameras: Query<&Camera>,
nodes: Query<(Option<&ComputedNode>, Option<&CalculatedClip>)>,
child_of: Query<&ChildOf>,
) {
for hits in hits.read() {
let Some(location) = pointers
.iter()
.find(|(id, _)| **id == hits.pointer)
.and_then(|(_, loc)| loc.location().cloned())
else {
continue;
};
hits.picks.retain(|(entity, data)| {
let mut cursor = *entity;
let clip = loop {
if let Ok((computed, clip)) = nodes.get(cursor)
&& computed.is_some()
{
break Some(clip);
}
match child_of.get(cursor) {
Ok(parent) => cursor = parent.parent(),
Err(_) => break None,
}
};
let Some(clip) = clip else {
return true; };
let Some(clip) = clip else {
return true; };
let Ok(camera) = cameras.get(data.camera) else {
return true;
};
clip.clip
.contains(pointer_physical_position(&location, camera))
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::camera::{ImageRenderTarget, NormalizedRenderTarget};
use bevy::ecs::system::RunSystemOnce;
use bevy::picking::backend::HitData;
use bevy::picking::pointer::Location;
const POINTER: PointerId = PointerId::Custom(uuid::Uuid::from_u128(0xC11B));
fn world_with_pointer(position: Vec2) -> (World, Entity) {
let mut world = World::new();
world.init_resource::<Messages<PointerHits>>();
let camera = world.spawn(Camera::default()).id();
world.spawn((
POINTER,
PointerLocation::new(Location {
target: NormalizedRenderTarget::Image(ImageRenderTarget {
handle: Handle::default(),
scale_factor: 1.0,
}),
position,
}),
));
(world, camera)
}
fn send_hits(world: &mut World, camera: Entity, entities: &[Entity]) {
let picks = entities
.iter()
.map(|&e| (e, HitData::new(camera, 0.0, None, None)))
.collect();
world
.resource_mut::<Messages<PointerHits>>()
.write(PointerHits::new(POINTER, picks, 0.5));
}
fn surviving_picks(world: &mut World) -> Vec<Entity> {
world
.resource_mut::<Messages<PointerHits>>()
.drain()
.flat_map(|h| h.picks.into_iter().map(|(e, _)| e))
.collect()
}
#[test]
fn clipped_node_hits_are_filtered_by_calculated_clip() {
for (pointer, expect_kept) in [
(Vec2::new(50.0, 50.0), true),
(Vec2::new(50.0, 150.0), false),
] {
let (mut world, camera) = world_with_pointer(pointer);
let node = world
.spawn((
ComputedNode::default(),
CalculatedClip {
clip: Rect::new(0.0, 0.0, 100.0, 100.0),
},
))
.id();
send_hits(&mut world, camera, &[node]);
world.run_system_once(filter_clipped_pointer_hits).unwrap();
let kept = surviving_picks(&mut world);
assert_eq!(
kept.contains(&node),
expect_kept,
"pointer {pointer:?} → kept {kept:?}"
);
}
}
#[test]
fn span_leaf_resolves_clip_through_its_node() {
let (mut world, camera) = world_with_pointer(Vec2::new(50.0, 150.0));
let node = world
.spawn((
ComputedNode::default(),
CalculatedClip {
clip: Rect::new(0.0, 0.0, 100.0, 100.0),
},
))
.id();
let span = world.spawn(ChildOf(node)).id();
send_hits(&mut world, camera, &[span]);
world.run_system_once(filter_clipped_pointer_hits).unwrap();
assert!(
surviving_picks(&mut world).is_empty(),
"a span hit outside its node's clip must be dropped"
);
}
#[test]
fn mesh_and_unclipped_hits_pass_through() {
let (mut world, camera) = world_with_pointer(Vec2::new(50.0, 150.0));
let mesh = world.spawn_empty().id();
let unclipped = world.spawn(ComputedNode::default()).id();
send_hits(&mut world, camera, &[mesh, unclipped]);
world.run_system_once(filter_clipped_pointer_hits).unwrap();
assert_eq!(surviving_picks(&mut world), vec![mesh, unclipped]);
}
}