Skip to main content

bevy_react/protocol/
transform.rs

1//! The static `transform` / `transform3d` wire types mirroring the animated
2//! transform channels.
3
4use serde::Deserialize;
5
6use super::animatable::{Animatable, AnimatableField};
7use super::units::{Angle, Length};
8
9/// A static 2D transform mirroring the animated transform channels. Every field
10/// is optional; unset channels stay at identity (no translation, unit scale, no
11/// rotation). `scale` is uniform; `scaleX`/`scaleY` override a single axis.
12#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct Transform {
15    /// Translation along x — a length (number = logical pixels, or a unit string
16    /// like `"50%"`, resolved against the node's own size by `bevy_ui`).
17    pub translate_x: Option<Animatable<Length>>,
18    /// Translation along y — a length (number = logical pixels, or a unit string
19    /// like `"50%"`).
20    pub translate_y: Option<Animatable<Length>>,
21    /// Uniform scale (both axes), unless overridden by `scale_x`/`scale_y`.
22    pub scale: Option<Animatable<f32>>,
23    pub scale_x: Option<Animatable<f32>>,
24    pub scale_y: Option<Animatable<f32>>,
25    /// Clockwise rotation (number = degrees, or a unit string like `"1.5rad"`).
26    /// An animated binding's per-frame values are read as **degrees** too.
27    pub rotate: Option<Animatable<Angle>>,
28}
29
30/// The `transform3d` style: a 3D perspective transform composited onto a
31/// promoted layer's quad (see [`super::style::Style::transform3d`]). Every field is optional;
32/// unset channels stay at identity. Canonical application order around
33/// [`origin`](Self::origin): scale → rotateX → rotateY → rotateZ → translate,
34/// then the self-`perspective` projection.
35#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct Transform3d {
38    /// Self-perspective focal distance in logical pixels (CSS
39    /// `transform: perspective(d)`), the vanishing point at `origin`. Unset →
40    /// orthographic (rotations foreshorten but nothing diverges with depth).
41    pub perspective: Option<Animatable<f32>>,
42    /// Translation along x in logical pixels.
43    pub translate_x: Option<Animatable<f32>>,
44    /// Translation along y in logical pixels.
45    pub translate_y: Option<Animatable<f32>>,
46    /// Translation along z in logical pixels. Positive is toward the viewer —
47    /// visible only with `perspective`.
48    pub translate_z: Option<Animatable<f32>>,
49    /// Rotation about the x axis (number = degrees, or `"1.5rad"`).
50    pub rotate_x: Option<Animatable<Angle>>,
51    /// Rotation about the y axis (number = degrees, or `"1.5rad"`).
52    pub rotate_y: Option<Animatable<Angle>>,
53    /// Rotation about the z axis (number = degrees, or `"1.5rad"`).
54    pub rotate_z: Option<Animatable<Angle>>,
55    /// Uniform scale (x and y), unless overridden by `scale_x`/`scale_y`.
56    pub scale: Option<Animatable<f32>>,
57    pub scale_x: Option<Animatable<f32>>,
58    pub scale_y: Option<Animatable<f32>>,
59    /// Pivot for rotation/scale and the perspective vanishing point, relative
60    /// to the node's border box. Defaults to the center (`50%`/`50%`).
61    pub origin: Option<Transform3dOrigin>,
62}
63
64impl Transform3d {
65    /// Whether every channel is at identity (an empty `{}` or explicit identity
66    /// values). Promotion is presence-based and ignores this; the render and
67    /// picking paths use it to skip transform work entirely.
68    pub fn is_identity(&self) -> bool {
69        let Self {
70            perspective,
71            translate_x,
72            translate_y,
73            translate_z,
74            rotate_x,
75            rotate_y,
76            rotate_z,
77            scale,
78            scale_x,
79            scale_y,
80            origin: _, // the pivot of an identity transform is irrelevant
81        } = self;
82        // An animated channel is never identity: the binding drives it to
83        // arbitrary values each frame (`static_val()` is `None`, so the
84        // `unwrap_or(identity)` shortcut would wrongly report identity).
85        let no_binding = |f: &Option<Animatable<f32>>| f.binding().is_none();
86        let no_angle_binding = |f: &Option<Animatable<Angle>>| f.binding().is_none();
87        perspective.is_none()
88            && [
89                translate_x,
90                translate_y,
91                translate_z,
92                scale,
93                scale_x,
94                scale_y,
95            ]
96            .iter()
97            .all(|f| no_binding(f))
98            && [rotate_x, rotate_y, rotate_z]
99                .iter()
100                .all(|f| no_angle_binding(f))
101            && translate_x.static_val().unwrap_or(0.0) == 0.0
102            && translate_y.static_val().unwrap_or(0.0) == 0.0
103            && translate_z.static_val().unwrap_or(0.0) == 0.0
104            && rotate_x.static_val().unwrap_or_default().radians() == 0.0
105            && rotate_y.static_val().unwrap_or_default().radians() == 0.0
106            && rotate_z.static_val().unwrap_or_default().radians() == 0.0
107            && scale.static_val().unwrap_or(1.0) == 1.0
108            && scale_x.static_val().unwrap_or(1.0) == 1.0
109            && scale_y.static_val().unwrap_or(1.0) == 1.0
110    }
111}
112
113/// The `transform3d` pivot: a per-axis [`Length`] resolved against the node's
114/// border box (`"50%"` = center, a number = logical pixels from the top-left).
115#[derive(Debug, Clone, PartialEq, Deserialize)]
116pub struct Transform3dOrigin {
117    pub x: Animatable<Length>,
118    pub y: Animatable<Length>,
119}
120
121impl Default for Transform3dOrigin {
122    fn default() -> Self {
123        Self {
124            x: Animatable::Static(Length::Percent(50.0)),
125            y: Animatable::Static(Length::Percent(50.0)),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::protocol::props::{Props, props_from_json as props};
134    use crate::protocol::style::{Style, style_groups};
135
136    /// A style carries `transform`/`opacity`/`transition` over the wire (transform
137    /// as a nested object, transition's `transform` entry resolving to a timing).
138    #[test]
139    fn deserializes_transform_opacity_and_transition() {
140        let s: Style = serde_json::from_str(
141            r#"{
142                "transform": { "scale": 0.95, "translateX": 4, "translateY": "50%" },
143                "opacity": 0.5,
144                "transition": { "transform": { "duration": 0.15, "easing": "easeOut" } }
145            }"#,
146        )
147        .expect("style decodes");
148        let t = s.transform.expect("transform present");
149        assert_eq!(t.scale.static_val(), Some(0.95));
150        // A bare number is logical pixels; a unit string carries an explicit unit.
151        assert_eq!(t.translate_x.static_val(), Some(Length::Px(4.0)));
152        assert_eq!(t.translate_y.static_val(), Some(Length::Percent(50.0)));
153        assert_eq!(t.scale_x, None);
154        assert_eq!(s.opacity.static_val(), Some(0.5));
155        let transition = s.transition.expect("transition present");
156        assert!(transition.for_transform().is_some());
157        assert!(transition.for_opacity().is_none());
158    }
159
160    /// `transform3d` decodes its full field set (degrees and rad-string angles,
161    /// percent-or-px origin), a malformed angle falls back to identity, an
162    /// empty object is identity, and a wire delta dirties
163    /// `TRANSFORM3D | LAYER | TRANSITION`.
164    #[test]
165    fn deserializes_transform3d() {
166        let s: Style = serde_json::from_str(
167            r#"{
168                "transform3d": {
169                    "perspective": 800,
170                    "translateZ": -20,
171                    "rotateY": 45,
172                    "rotateX": "1.5rad",
173                    "rotateZ": "not-an-angle",
174                    "scale": 1.25,
175                    "origin": { "x": "50%", "y": 10 }
176                }
177            }"#,
178        )
179        .expect("style decodes");
180        let t = s.transform3d.clone().expect("transform3d present");
181        assert_eq!(t.perspective.static_val(), Some(800.0));
182        assert_eq!(t.translate_z.static_val(), Some(-20.0));
183        assert_eq!(
184            t.rotate_y.static_val().unwrap().radians(),
185            45f32.to_radians()
186        );
187        assert_eq!(t.rotate_x.static_val().unwrap().radians(), 1.5);
188        // Malformed angle → warn-and-identity, never a decode failure.
189        assert_eq!(t.rotate_z.static_val().unwrap().radians(), 0.0);
190        assert_eq!(t.scale.static_val(), Some(1.25));
191        let origin = t.origin.clone().expect("origin present");
192        assert_eq!(origin.x.value(), Some(&Length::Percent(50.0)));
193        assert_eq!(origin.y.value(), Some(&Length::Px(10.0)));
194        assert!(!t.is_identity());
195
196        let s: Style = serde_json::from_str(r#"{ "transform3d": {} }"#).expect("style decodes");
197        assert!(s.transform3d.expect("present").is_identity());
198
199        let mut cached = Props::default();
200        let (dirty, _) = cached.merge_delta(
201            props(serde_json::json!({ "style": { "transform3d": { "rotateY": 45 } } })),
202            &[],
203            &[],
204        );
205        assert!(dirty.style.intersects(style_groups::TRANSFORM3D));
206        assert!(dirty.style.intersects(style_groups::LAYER));
207        assert!(dirty.style.intersects(style_groups::TRANSITION));
208    }
209}