use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use super::{LayerContentDirt, PromotedLayer};
use crate::protocol::{self, AnimatableField, Length, Transform3d};
#[derive(Component, Debug, Clone, PartialEq)]
pub struct LayerTransform3d(pub Transform3d);
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct LayerTransform3dMatrix {
pub model: Mat4,
pub identity: bool,
}
fn resolve_origin_axis(len: Length, extent: f32, scale_factor: f32) -> (f32, bool) {
match len {
Length::Px(px) => (px * scale_factor, false),
Length::Percent(pct) => (extent * pct / 100.0, false),
_ => (extent * 0.5, true),
}
}
pub fn resolve_origin(params: &Transform3d, size: Vec2, scale_factor: f32) -> (Vec2, bool) {
let origin = params.origin.clone().unwrap_or_default();
let axis = |a: &crate::protocol::Animatable<Length>| {
a.value().copied().unwrap_or(Length::Percent(50.0))
};
let (x, warn_x) = resolve_origin_axis(axis(&origin.x), size.x, scale_factor);
let (y, warn_y) = resolve_origin_axis(axis(&origin.y), size.y, scale_factor);
(Vec2::new(x, y), warn_x || warn_y)
}
pub fn build_transform3d_matrix(
params: &Transform3d,
border_min: Vec2,
border_size: Vec2,
scale_factor: f32,
) -> Mat4 {
let (origin_offset, _) = resolve_origin(params, border_size, scale_factor);
let o = (border_min + origin_offset).extend(0.0);
let uniform = params.scale.static_val().unwrap_or(1.0);
let scale = Mat4::from_scale(Vec3::new(
params.scale_x.static_val().unwrap_or(uniform),
params.scale_y.static_val().unwrap_or(uniform),
1.0,
));
let rx = Mat4::from_rotation_x(params.rotate_x.static_val().unwrap_or_default().radians());
let ry = Mat4::from_rotation_y(params.rotate_y.static_val().unwrap_or_default().radians());
let rz = Mat4::from_rotation_z(params.rotate_z.static_val().unwrap_or_default().radians());
let translate = Mat4::from_translation(
Vec3::new(
params.translate_x.static_val().unwrap_or(0.0),
params.translate_y.static_val().unwrap_or(0.0),
params.translate_z.static_val().unwrap_or(0.0),
) * scale_factor,
);
let mut perspective = Mat4::IDENTITY;
if let Some(d) = params.perspective.static_val().filter(|d| *d > 0.0) {
perspective.z_axis.w = -1.0 / (d * scale_factor);
}
Mat4::from_translation(o)
* perspective
* translate
* rz
* ry
* rx
* scale
* Mat4::from_translation(-o)
}
#[allow(clippy::type_complexity)]
pub fn sync_transform3d_matrices(
mut commands: Commands,
roots: Query<
(
Entity,
&ComputedNode,
&UiGlobalTransform,
&LayerTransform3d,
Option<&LayerTransform3dMatrix>,
&crate::bridge::RNode,
),
With<PromotedLayer>,
>,
mut dirt: ResMut<LayerContentDirt>,
) {
for (entity, computed, transform, params, existing, rnode) in &roots {
let size = computed.size();
if size.x <= 0.5 || size.y <= 0.5 {
continue; }
let min = transform.translation - size * 0.5;
let scale_factor = 1.0 / computed.inverse_scale_factor();
let (_, origin_fallback) = resolve_origin(¶ms.0, size, scale_factor);
if origin_fallback {
let _diag = crate::diag::node_scope(rnode.0);
crate::diag::report(
"length",
"origin",
"transform3d origin supports px and % only; falling back to 50%",
);
}
let next = LayerTransform3dMatrix {
model: build_transform3d_matrix(¶ms.0, min, size, scale_factor),
identity: params.0.is_identity(),
};
if existing != Some(&next) {
commands.entity(entity).insert(next);
dirt.composite_only.push(entity);
}
}
}
pub fn style_transform3d(style: &Option<protocol::Style>) -> Option<Transform3d> {
style.as_ref().and_then(|s| s.transform3d.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::Transform3dOrigin;
fn deg(v: f32) -> Option<crate::protocol::Animatable<crate::protocol::Angle>> {
serde_json::from_value(serde_json::json!(v)).ok()
}
fn st(v: f32) -> Option<crate::protocol::Animatable<f32>> {
Some(crate::protocol::Animatable::Static(v))
}
fn ax(l: Length) -> crate::protocol::Animatable<Length> {
crate::protocol::Animatable::Static(l)
}
#[test]
fn origin_is_the_fixed_point() {
let params = Transform3d {
rotate_z: deg(45.0),
rotate_y: deg(30.0),
scale: st(2.0),
origin: Some(Transform3dOrigin {
x: ax(Length::Px(10.0)),
y: ax(Length::Px(20.0)),
}),
..Default::default()
};
let m =
build_transform3d_matrix(¶ms, Vec2::new(100.0, 200.0), Vec2::new(50.0, 50.0), 1.0);
let o = Vec3::new(110.0, 220.0, 0.0);
assert!(m.project_point3(o).abs_diff_eq(o, 1e-3));
}
#[test]
fn rotate_y_90_collapses_x() {
let params = Transform3d {
rotate_y: deg(90.0),
..Default::default()
};
let m = build_transform3d_matrix(
¶ms,
Vec2::new(100.0, 50.0),
Vec2::new(100.0, 100.0),
1.0,
);
let p = m.project_point3(Vec3::new(180.0, 60.0, 0.0));
assert!(
(p.x - 150.0).abs() < 1e-3,
"x collapsed to origin.x, got {}",
p.x
);
assert!((p.y - 60.0).abs() < 1e-3, "y untouched, got {}", p.y);
}
#[test]
fn perspective_divide_magnifies_toward_viewer() {
let params = Transform3d {
perspective: st(100.0),
translate_z: st(50.0),
origin: Some(Transform3dOrigin {
x: ax(Length::Px(0.0)),
y: ax(Length::Px(0.0)),
}),
..Default::default()
};
let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(100.0, 100.0), 1.0);
let p = m.project_point3(Vec3::new(10.0, 6.0, 0.0));
assert!(p.xy().abs_diff_eq(Vec2::new(20.0, 12.0), 1e-3), "got {p}");
let raw = m * Vec4::new(10.0, 6.0, 0.0, 1.0);
assert!((raw.w - 0.5).abs() < 1e-4);
}
#[test]
fn per_axis_scale_overrides_uniform() {
let params = Transform3d {
scale: st(2.0),
scale_x: st(3.0),
origin: Some(Transform3dOrigin {
x: ax(Length::Px(0.0)),
y: ax(Length::Px(0.0)),
}),
..Default::default()
};
let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(10.0, 10.0), 1.0);
let p = m.project_point3(Vec3::new(1.0, 1.0, 0.0));
assert!(p.xy().abs_diff_eq(Vec2::new(3.0, 2.0), 1e-4));
}
#[test]
fn scale_factor_converts_logical_lengths() {
let params = Transform3d {
translate_x: st(10.0),
origin: Some(Transform3dOrigin {
x: ax(Length::Px(5.0)),
y: ax(Length::Percent(50.0)),
}),
..Default::default()
};
let (offset, warned) = resolve_origin(¶ms, Vec2::new(100.0, 100.0), 2.0);
assert!(!warned);
assert_eq!(offset, Vec2::new(10.0, 50.0));
let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(100.0, 100.0), 2.0);
let p = m.project_point3(Vec3::ZERO);
assert!(p.xy().abs_diff_eq(Vec2::new(20.0, 0.0), 1e-4));
}
#[test]
fn sync_pushes_composite_only_dirt_on_change() {
use bevy::ecs::system::RunSystemOnce;
use bevy::math::Affine2;
let mut world = World::new();
world.init_resource::<LayerContentDirt>();
let root = world
.spawn((
ComputedNode {
size: Vec2::new(100.0, 50.0),
..Default::default()
},
UiGlobalTransform::from(Affine2::from_translation(Vec2::new(200.0, 100.0))),
LayerTransform3d(Transform3d {
rotate_y: deg(30.0),
..Default::default()
}),
PromotedLayer {
reasons: super::super::PromotionReasons(
super::super::PromotionReasons::TRANSFORM3D,
),
},
crate::bridge::RNode(7),
))
.id();
world.run_system_once(sync_transform3d_matrices).unwrap();
let dirt = world.resource::<LayerContentDirt>();
assert_eq!(dirt.composite_only, vec![root], "first build dirties");
assert!(dirt.nodes.is_empty(), "never content dirt");
let matrix = world.get::<LayerTransform3dMatrix>(root).expect("derived");
assert!(!matrix.identity);
world
.resource_mut::<LayerContentDirt>()
.composite_only
.clear();
world.run_system_once(sync_transform3d_matrices).unwrap();
assert!(
world
.resource::<LayerContentDirt>()
.composite_only
.is_empty()
);
world.get_mut::<LayerTransform3d>(root).unwrap().0.rotate_y = deg(60.0);
world.run_system_once(sync_transform3d_matrices).unwrap();
assert_eq!(
world.resource::<LayerContentDirt>().composite_only,
vec![root]
);
}
#[test]
fn origin_fallback_and_bad_perspective() {
let params = Transform3d {
origin: Some(Transform3dOrigin {
x: ax(Length::Auto),
y: ax(Length::Px(0.0)),
}),
perspective: st(0.0),
translate_z: st(50.0),
..Default::default()
};
let (offset, warned) = resolve_origin(¶ms, Vec2::new(80.0, 60.0), 1.0);
assert!(warned);
assert_eq!(offset.x, 40.0);
let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(80.0, 60.0), 1.0);
let raw = m * Vec4::new(10.0, 10.0, 0.0, 1.0);
assert_eq!(raw.w, 1.0);
}
}