1use 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
44pub const TRANSFORM3D_POINTER_UUID: uuid::Uuid = uuid::Uuid::from_u128(0x7D3D_D001);
47
48const FORWARDED_BUTTONS: [(MouseButton, PointerButton); 3] = [
51 (MouseButton::Left, PointerButton::Primary),
52 (MouseButton::Right, PointerButton::Secondary),
53 (MouseButton::Middle, PointerButton::Middle),
54];
55
56fn button_index(button: PointerButton) -> usize {
58 match button {
59 PointerButton::Primary => 0,
60 PointerButton::Secondary => 1,
61 PointerButton::Middle => 2,
62 }
63}
64
65#[derive(Resource)]
69pub struct Transform3dPointer {
70 pub id: PointerId,
73 pub over_layer: Option<Entity>,
77 last_pos: Vec2,
79 last_target: Option<bevy::camera::NormalizedRenderTarget>,
82 pressed: [bool; FORWARDED_BUTTONS.len()],
84}
85
86pub fn init_transform3d_pointer(mut commands: Commands) {
88 let id = PointerId::Custom(TRANSFORM3D_POINTER_UUID);
89 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
100fn 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
111pub 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; }
121 let p = h.inverse() * screen.extend(1.0);
122 if p.z.abs() < 1e-6 {
123 return None; }
125 let local = p.truncate() / p.z;
126 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
136fn 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
157fn 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#[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 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 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; let location = Location {
255 target: target.clone(),
256 position,
257 };
258 let delta = position - state.last_pos;
259 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 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
322pub 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 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#[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 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 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 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
434pub 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 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 fn st(v: f32) -> Option<crate::protocol::animatable::Animatable<f32>> {
485 Some(crate::protocol::animatable::Animatable::Static(v))
486 }
487
488 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 #[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(¶ms, 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 #[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 #[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 send(&mut world, PointerId::Mouse, &[member, unrelated]);
615 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 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 #[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 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 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}