use bevy::ecs::message::MessageMutator;
use bevy::input::ButtonInput;
use bevy::input::mouse::MouseButton;
use bevy::picking::backend::PointerHits;
use bevy::picking::hover::HoverMap;
use bevy::picking::pointer::{
Location, PointerAction, PointerButton, PointerId, PointerInput, PointerLocation, PointerPress,
};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform, UiStack};
use bevy::window::PrimaryWindow;
use super::transform3d::LayerTransform3dMatrix;
use super::{LayerMembership, PromotedLayer};
pub const TRANSFORM3D_POINTER_UUID: uuid::Uuid = uuid::Uuid::from_u128(0x7D3D_D001);
const FORWARDED_BUTTONS: [(MouseButton, PointerButton); 3] = [
(MouseButton::Left, PointerButton::Primary),
(MouseButton::Right, PointerButton::Secondary),
(MouseButton::Middle, PointerButton::Middle),
];
fn button_index(button: PointerButton) -> usize {
match button {
PointerButton::Primary => 0,
PointerButton::Secondary => 1,
PointerButton::Middle => 2,
}
}
#[derive(Resource)]
pub struct Transform3dPointer {
pub id: PointerId,
pub over_layer: Option<Entity>,
last_pos: Vec2,
last_target: Option<bevy::camera::NormalizedRenderTarget>,
pressed: [bool; FORWARDED_BUTTONS.len()],
}
pub fn init_transform3d_pointer(mut commands: Commands) {
let id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
commands.spawn(id);
commands.insert_resource(Transform3dPointer {
id,
over_layer: None,
last_pos: Vec2::ZERO,
last_target: None,
pressed: [false; FORWARDED_BUTTONS.len()],
});
}
fn screen_homography(m: &Mat4) -> Mat3 {
Mat3::from_cols(
Vec3::new(m.x_axis.x, m.x_axis.y, m.x_axis.w),
Vec3::new(m.y_axis.x, m.y_axis.y, m.y_axis.w),
Vec3::new(m.w_axis.x, m.w_axis.y, m.w_axis.w),
)
}
pub fn invert_screen_to_plane(model: &Mat4, screen: Vec2) -> Option<Vec2> {
let h = screen_homography(model);
if h.determinant().abs() < 1e-6 {
return None; }
let p = h.inverse() * screen.extend(1.0);
if p.z.abs() < 1e-6 {
return None; }
let local = p.truncate() / p.z;
let w = h.x_axis.z * local.x + h.y_axis.z * local.y + h.z_axis.z;
if w <= 0.0 {
return None;
}
Some(local)
}
fn transformed_root_of(
entity: Entity,
membership: &LayerMembership,
matrices: &Query<&LayerTransform3dMatrix>,
child_of: &Query<&ChildOf>,
) -> Option<Entity> {
let member = crate::reconcile::climb(entity, child_of, |e| {
membership.node_to_layer.contains_key(&e)
})?;
let mut root = *membership.node_to_layer.get(&member)?;
loop {
if matrices.get(root).is_ok_and(|m| !m.identity) {
return Some(root);
}
root = (*membership.enclosing.get(&root)?)?;
}
}
fn member_of_layer(
entity: Entity,
layer: Entity,
membership: &LayerMembership,
child_of: &Query<&ChildOf>,
) -> bool {
let Some(member) = crate::reconcile::climb(entity, child_of, |e| {
membership.node_to_layer.contains_key(&e)
}) else {
return false;
};
let mut root = match membership.node_to_layer.get(&member) {
Some(&root) => root,
None => return false,
};
loop {
if root == layer {
return true;
}
match membership.enclosing.get(&root) {
Some(Some(outer)) => root = *outer,
_ => return false,
}
}
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn drive_transform3d_pointer(
mut state: ResMut<Transform3dPointer>,
layers: Query<
(
Entity,
&ComputedNode,
&UiGlobalTransform,
&LayerTransform3dMatrix,
),
With<PromotedLayer>,
>,
ui_stack: Res<UiStack>,
pointers: Query<(&PointerId, &PointerLocation)>,
windows: Query<&Window, With<PrimaryWindow>>,
buttons: Res<ButtonInput<MouseButton>>,
mut input: MessageWriter<PointerInput>,
) {
let pointer_id = state.id;
let mouse = pointers
.iter()
.find(|(id, _)| matches!(id, PointerId::Mouse))
.and_then(|(_, loc)| loc.location().cloned());
let scale = windows.single().map(|w| w.scale_factor()).unwrap_or(1.0);
let candidate = mouse.as_ref().and_then(|loc| {
let cursor = loc.position * scale;
let mut best: Option<(usize, Entity, Vec2)> = None;
for (root, computed, transform, matrix) in &layers {
if matrix.identity {
continue;
}
let size = computed.size();
if size.x <= 0.5 || size.y <= 0.5 {
continue;
}
let Some(local) = invert_screen_to_plane(&matrix.model, cursor) else {
continue;
};
let min = transform.translation - size * 0.5;
if local.x < min.x
|| local.y < min.y
|| local.x > min.x + size.x
|| local.y > min.y + size.y
{
continue;
}
let index = ui_stack.uinodes.iter().position(|&e| e == root);
let index = index.unwrap_or(0);
if best.is_none_or(|(top, _, _)| index > top) {
best = Some((index, root, local));
}
}
best.map(|(_, root, local)| (root, local, loc.target.clone()))
});
if let Some((root, local, target)) = candidate {
let position = local / scale; let location = Location {
target: target.clone(),
position,
};
let delta = position - state.last_pos;
if delta != Vec2::ZERO || state.over_layer != Some(root) {
input.write(PointerInput::new(
pointer_id,
location.clone(),
PointerAction::Move { delta },
));
}
state.last_pos = position;
state.last_target = Some(target);
state.over_layer = Some(root);
for (mb, pb) in FORWARDED_BUTTONS {
if buttons.just_pressed(mb) {
input.write(PointerInput::new(
pointer_id,
location.clone(),
PointerAction::Press(pb),
));
state.pressed[button_index(pb)] = true;
}
if buttons.just_released(mb) && state.pressed[button_index(pb)] {
input.write(PointerInput::new(
pointer_id,
location.clone(),
PointerAction::Release(pb),
));
state.pressed[button_index(pb)] = false;
}
}
return;
}
if state.over_layer.is_some()
&& let Some(target) = state.last_target.clone()
{
let location = Location {
target,
position: Vec2::splat(-1.0),
};
for (_, pb) in FORWARDED_BUTTONS {
if state.pressed[button_index(pb)] {
input.write(PointerInput::new(
pointer_id,
location.clone(),
PointerAction::Release(pb),
));
state.pressed[button_index(pb)] = false;
}
}
input.write(PointerInput::new(
pointer_id,
location,
PointerAction::Move { delta: Vec2::ZERO },
));
state.over_layer = None;
state.last_pos = Vec2::splat(-1.0);
}
}
pub fn suppress_transformed_layer_hits(
mut hits: MessageMutator<PointerHits>,
state: Option<Res<Transform3dPointer>>,
membership: Res<LayerMembership>,
matrices: Query<&LayerTransform3dMatrix>,
child_of: Query<&ChildOf>,
) {
let Some(state) = state else {
return;
};
for hits in hits.read() {
if hits.pointer == state.id {
match state.over_layer {
Some(layer) => hits
.picks
.retain(|(entity, _)| member_of_layer(*entity, layer, &membership, &child_of)),
None => hits.picks.clear(),
}
} else if matches!(hits.pointer, PointerId::Mouse) {
hits.picks.retain(|(entity, _)| {
transformed_root_of(*entity, &membership, &matrices, &child_of).is_none()
});
}
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn correct_transformed_interactions(
state: Option<Res<Transform3dPointer>>,
hover_map: Option<Res<HoverMap>>,
pointers: Query<(&PointerId, &PointerPress, &PointerLocation)>,
windows: Query<&Window, With<PrimaryWindow>>,
membership: Res<LayerMembership>,
matrices: Query<&LayerTransform3dMatrix>,
child_of: Query<&ChildOf>,
mut interactions: Query<(
Entity,
&mut Interaction,
Option<&mut bevy::ui::RelativeCursorPosition>,
Option<&ComputedNode>,
Option<&UiGlobalTransform>,
)>,
) {
let Some(state) = state else {
return;
};
if !matrices.iter().any(|m| !m.identity) {
return;
}
let hovered = hover_map.as_ref().and_then(|map| map.get(&state.id));
let virtual_pointer = pointers.iter().find(|(id, _, _)| **id == state.id);
let pressed = virtual_pointer.is_some_and(|(_, press, _)| press.is_primary_pressed());
let scale = windows.single().map(|w| w.scale_factor()).unwrap_or(1.0);
let remapped_physical = state.over_layer.and_then(|_| {
virtual_pointer
.and_then(|(_, _, loc)| loc.location())
.map(|loc| loc.position * scale)
});
for (entity, mut interaction, rel, computed, transform) in &mut interactions {
if transformed_root_of(entity, &membership, &matrices, &child_of).is_none() {
continue;
}
let over = hovered.is_some_and(|map| map.contains_key(&entity));
let desired = if over {
if pressed {
Interaction::Pressed
} else {
Interaction::Hovered
}
} else {
Interaction::None
};
interaction.set_if_neq(desired);
if let Some(mut rel) = rel {
let normalized = remapped_physical.and_then(|pos| {
computed
.zip(transform)
.and_then(|(c, t)| c.normalize_point(*t, pos))
});
let next = bevy::ui::RelativeCursorPosition {
cursor_over: over,
normalized,
};
if rel.cursor_over != next.cursor_over || rel.normalized != next.normalized {
*rel = next;
}
}
}
}
pub fn visually_transformed_members(
membership: &LayerMembership,
matrices: &Query<&LayerTransform3dMatrix>,
) -> bevy::platform::collections::HashSet<Entity> {
let mut transformed_roots: Vec<Entity> = Vec::new();
for (&root, _) in membership.enclosing.iter() {
if matrices.get(root).is_ok_and(|m| !m.identity) {
transformed_roots.push(root);
}
}
if transformed_roots.is_empty() {
return Default::default();
}
membership
.node_to_layer
.iter()
.filter(|(_, own_root)| {
let mut root = **own_root;
loop {
if transformed_roots.contains(&root) {
return true;
}
match membership.enclosing.get(&root) {
Some(Some(outer)) => root = *outer,
_ => return false,
}
}
})
.map(|(&node, _)| node)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{transform::Transform3d, transform::Transform3dOrigin};
fn deg(
v: f32,
) -> Option<crate::protocol::animatable::Animatable<crate::protocol::units::Angle>> {
Some(crate::protocol::animatable::Animatable::Static(
crate::protocol::units::Angle::from_radians(v.to_radians()),
))
}
fn st(v: f32) -> Option<crate::protocol::animatable::Animatable<f32>> {
Some(crate::protocol::animatable::Animatable::Static(v))
}
fn ax(
l: crate::protocol::units::Length,
) -> crate::protocol::animatable::Animatable<crate::protocol::units::Length> {
crate::protocol::animatable::Animatable::Static(l)
}
#[test]
fn homography_inversion_round_trips() {
let params = Transform3d {
perspective: st(600.0),
rotate_y: deg(35.0),
rotate_x: deg(-12.0),
translate_x: st(30.0),
scale: st(1.2),
origin: Some(Transform3dOrigin {
x: ax(crate::protocol::units::Length::Percent(25.0)),
y: ax(crate::protocol::units::Length::Percent(50.0)),
}),
..Default::default()
};
let min = Vec2::new(300.0, 200.0);
let size = Vec2::new(240.0, 160.0);
let m = super::super::transform3d::build_transform3d_matrix(¶ms, min, size, 1.0);
for local in [
min,
min + size,
min + size * 0.5,
min + Vec2::new(10.0, 100.0),
] {
let screen = m.project_point3(local.extend(0.0)).truncate();
let back = invert_screen_to_plane(&m, screen).expect("invertible");
assert!(back.abs_diff_eq(local, 1e-2), "{local} → {screen} → {back}");
}
}
#[test]
fn edge_on_misses_backface_hits() {
let base = Transform3d {
origin: Some(Transform3dOrigin {
x: ax(crate::protocol::units::Length::Percent(50.0)),
y: ax(crate::protocol::units::Length::Percent(50.0)),
}),
..Default::default()
};
let min = Vec2::ZERO;
let size = Vec2::new(100.0, 100.0);
let edge_on = Transform3d {
rotate_y: deg(90.0),
..base.clone()
};
let m = super::super::transform3d::build_transform3d_matrix(&edge_on, min, size, 1.0);
assert!(invert_screen_to_plane(&m, Vec2::new(50.0, 50.0)).is_none());
let backface = Transform3d {
rotate_y: deg(150.0),
perspective: st(800.0),
..base
};
let m = super::super::transform3d::build_transform3d_matrix(&backface, min, size, 1.0);
let screen = m.project_point3(Vec3::new(30.0, 40.0, 0.0)).truncate();
let back = invert_screen_to_plane(&m, screen).expect("backface inverts");
assert!(back.abs_diff_eq(Vec2::new(30.0, 40.0), 1e-2));
}
#[test]
fn suppression_scopes_hits_per_pointer() {
use bevy::ecs::system::RunSystemOnce;
use bevy::picking::backend::HitData;
let mut world = World::new();
world.init_resource::<Messages<PointerHits>>();
let camera = world.spawn_empty().id();
let transformed_root = world
.spawn(LayerTransform3dMatrix {
model: Mat4::from_rotation_y(0.5),
identity: false,
})
.id();
let member = world.spawn(ChildOf(transformed_root)).id();
let unrelated = world.spawn_empty().id();
let mut membership = LayerMembership::default();
membership
.node_to_layer
.insert(transformed_root, transformed_root);
membership.node_to_layer.insert(member, transformed_root);
membership.enclosing.insert(transformed_root, None);
world.insert_resource(membership);
let virtual_id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
world.insert_resource(Transform3dPointer {
id: virtual_id,
over_layer: Some(transformed_root),
last_pos: Vec2::ZERO,
last_target: None,
pressed: [false; 3],
});
let send = |world: &mut World, pointer: PointerId, 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));
};
let survivors = |world: &mut World| -> Vec<(PointerId, Vec<Entity>)> {
world
.resource_mut::<Messages<PointerHits>>()
.drain()
.map(|h| (h.pointer, h.picks.into_iter().map(|(e, _)| e).collect()))
.collect()
};
send(&mut world, PointerId::Mouse, &[member, unrelated]);
send(&mut world, virtual_id, &[member, unrelated]);
world
.run_system_once(suppress_transformed_layer_hits)
.unwrap();
let got = survivors(&mut world);
assert_eq!(got[0], (PointerId::Mouse, vec![unrelated]));
assert_eq!(got[1], (virtual_id, vec![member]));
world.resource_mut::<Transform3dPointer>().over_layer = None;
send(&mut world, virtual_id, &[member, unrelated]);
world
.run_system_once(suppress_transformed_layer_hits)
.unwrap();
let got = survivors(&mut world);
assert_eq!(got[0].1, Vec::<Entity>::new());
}
#[test]
fn interaction_correction_follows_virtual_pointer() {
use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::RunSystemOnce;
use bevy::picking::backend::HitData;
let mut world = World::new();
let camera = world.spawn_empty().id();
let root = world
.spawn(LayerTransform3dMatrix {
model: Mat4::from_rotation_y(0.5),
identity: false,
})
.id();
let member = world.spawn((ChildOf(root), Interaction::None)).id();
let outside = world.spawn(Interaction::Hovered).id();
let mut membership = LayerMembership::default();
membership.node_to_layer.insert(root, root);
membership.node_to_layer.insert(member, root);
membership.enclosing.insert(root, None);
world.insert_resource(membership);
let virtual_id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
world.spawn((virtual_id, PointerPress::default()));
world.insert_resource(Transform3dPointer {
id: virtual_id,
over_layer: Some(root),
last_pos: Vec2::ZERO,
last_target: None,
pressed: [false; 3],
});
let mut hover = HoverMap::default();
let mut entry: EntityHashMap<HitData> = EntityHashMap::default();
entry.insert(member, HitData::new(camera, 0.0, None, None));
hover.insert(virtual_id, entry);
world.insert_resource(hover);
world
.run_system_once(correct_transformed_interactions)
.unwrap();
assert_eq!(
*world.get::<Interaction>(member).unwrap(),
Interaction::Hovered
);
assert_eq!(
*world.get::<Interaction>(outside).unwrap(),
Interaction::Hovered,
"nodes outside transformed layers are untouched"
);
if let Some(mut i) = world.get_mut::<Interaction>(member) {
*i = Interaction::Hovered;
}
world.insert_resource({
let mut hover = HoverMap::default();
hover.insert(virtual_id, EntityHashMap::default());
hover
});
world
.run_system_once(correct_transformed_interactions)
.unwrap();
assert_eq!(
*world.get::<Interaction>(member).unwrap(),
Interaction::None
);
}
}