Skip to main content

bevy_react/layer/
transform3d.rs

1//! The composite-time 3D transform on a promoted layer (`transform3d` style).
2//!
3//! The style's presence promotes the subtree ([`PromotionReasons::TRANSFORM3D`]);
4//! this module owns what happens after: the raw wire params live in
5//! [`LayerTransform3d`] (written by the style applier, overwritten per-frame by
6//! the transition/animation drivers), and [`sync_transform3d_matrices`] derives
7//! the [`LayerTransform3dMatrix`] the render side composites the quad through.
8//! The matrix reshapes the *composite quad only* — the capture, layout,
9//! and every main-world system see the untransformed node — so a matrix
10//! change is composite-only dirt: it never re-captures the layer itself, only
11//! re-draws the enclosing chain (same cost model as translation/group alpha).
12//!
13//! Space conventions: the matrix is built in **physical screen px** (the
14//! space of composite-quad vertices and `UiGlobalTransform`), x right,
15//! y down, z toward the viewer (CSS's screen space). Wire lengths are logical
16//! px and scale by the node's scale factor; angles arrive as radians from
17//! [`protocol::units::Angle`]. Self-perspective divides by `w = 1 − z/d` so positive
18//! `translateZ` moves toward the viewer and magnifies, with the vanishing
19//! point at the resolved `origin`.
20
21use bevy::prelude::*;
22use bevy::ui::{ComputedNode, UiGlobalTransform};
23
24use super::{LayerContentDirt, PromotedLayer};
25use crate::protocol::{self, animatable::AnimatableField, transform::Transform3d, units::Length};
26
27/// The `transform3d` style params on a promoted layer root, exactly as merged
28/// from the wire (base style + active interaction variants). Written by
29/// `apply_style_masked` under the `TRANSFORM3D` group; the transition and
30/// animation drivers overwrite it per-frame. Removed on demotion (see
31/// `evaluate_layer_promotions`) — style apply never removes it, mirroring the
32/// `UiTransform` never-remove rule.
33#[derive(Component, Debug, Clone, PartialEq)]
34pub struct LayerTransform3d(pub Transform3d);
35
36/// The matrix derived from [`LayerTransform3d`] + this frame's layout, in
37/// physical screen px. Separate from the params component so per-frame
38/// rebuilds (layout moves, animation) aren't style-state changes — the same
39/// split as `LayerGroupAlpha`. Consumed by render extraction (composite quad)
40/// and by the transformed-picking driver (`layer::pick3d`).
41#[derive(Component, Debug, Clone, Copy, PartialEq)]
42pub struct LayerTransform3dMatrix {
43    /// Maps untransformed physical-screen quad positions to their transformed
44    /// homogeneous position (real `w`: consumers must perspective-divide, and
45    /// the composite vertex stage passes `w` through for perspective-correct
46    /// interpolation).
47    pub model: Mat4,
48    /// Whether the params are identity — the render and picking fast path
49    /// (identity layers behave exactly like untransformed ones; promotion
50    /// itself is value-blind).
51    pub identity: bool,
52}
53
54/// Resolve one `origin` axis against the border-box extent (physical px).
55/// `Px` is a logical-px offset from the box's min edge; `Percent` is a
56/// fraction of the extent. Anything else (`auto`, viewport units) has no
57/// sensible meaning for a pivot — fall back to center and report.
58fn resolve_origin_axis(len: Length, extent: f32, scale_factor: f32) -> (f32, bool) {
59    match len {
60        Length::Px(px) => (px * scale_factor, false),
61        Length::Percent(pct) => (extent * pct / 100.0, false),
62        _ => (extent * 0.5, true),
63    }
64}
65
66/// The resolved pivot as an offset from the border-box min, in physical px,
67/// plus whether an unsupported unit fell back to center (caller reports —
68/// this stays pure for tests). Percent resolves against the **border box**,
69/// not the outset-inflated capture rect: a blur outset must not move the pivot.
70pub fn resolve_origin(params: &Transform3d, size: Vec2, scale_factor: f32) -> (Vec2, bool) {
71    let origin = params.origin.clone().unwrap_or_default();
72    // An animated axis reads as its default center until the animation applier
73    // overwrites the params with the evaluated static value each frame.
74    let axis = |a: &crate::protocol::animatable::Animatable<Length>| {
75        a.value().copied().unwrap_or(Length::Percent(50.0))
76    };
77    let (x, warn_x) = resolve_origin_axis(axis(&origin.x), size.x, scale_factor);
78    let (y, warn_y) = resolve_origin_axis(axis(&origin.y), size.y, scale_factor);
79    (Vec2::new(x, y), warn_x || warn_y)
80}
81
82/// Build the composite matrix for one layer in physical screen px.
83/// `border_min`/`border_size` are the node's border box (NOT the
84/// outset-inflated `LayerCaptureRect`). Canonical order around the resolved
85/// origin `o`: `M = T(o) · P(d) · T(t) · Rz · Ry · Rx · S · T(−o)` — column
86/// vectors, rightmost applies first, i.e. scale, then rotations, then
87/// translation, then the self-perspective projection, all about the pivot.
88pub fn build_transform3d_matrix(
89    params: &Transform3d,
90    border_min: Vec2,
91    border_size: Vec2,
92    scale_factor: f32,
93) -> Mat4 {
94    let (origin_offset, _) = resolve_origin(params, border_size, scale_factor);
95    let o = (border_min + origin_offset).extend(0.0);
96
97    // `scale` is uniform unless a per-axis override wins (same precedence as
98    // the 2D `build_ui_transform`). Z never scales: the subtree is a plane.
99    let uniform = params.scale.static_val().unwrap_or(1.0);
100    let scale = Mat4::from_scale(Vec3::new(
101        params.scale_x.static_val().unwrap_or(uniform),
102        params.scale_y.static_val().unwrap_or(uniform),
103        1.0,
104    ));
105    let rx = Mat4::from_rotation_x(params.rotate_x.static_val().unwrap_or_default().radians());
106    let ry = Mat4::from_rotation_y(params.rotate_y.static_val().unwrap_or_default().radians());
107    let rz = Mat4::from_rotation_z(params.rotate_z.static_val().unwrap_or_default().radians());
108    let translate = Mat4::from_translation(
109        Vec3::new(
110            params.translate_x.static_val().unwrap_or(0.0),
111            params.translate_y.static_val().unwrap_or(0.0),
112            params.translate_z.static_val().unwrap_or(0.0),
113        ) * scale_factor,
114    );
115    // Self-perspective: w' = 1 − z/d (z toward the viewer shrinks w →
116    // magnifies after the divide). A non-positive focal distance is
117    // meaningless — treat like unset (orthographic).
118    let mut perspective = Mat4::IDENTITY;
119    if let Some(d) = params.perspective.static_val().filter(|d| *d > 0.0) {
120        perspective.z_axis.w = -1.0 / (d * scale_factor);
121    }
122
123    Mat4::from_translation(o)
124        * perspective
125        * translate
126        * rz
127        * ry
128        * rx
129        * scale
130        * Mat4::from_translation(-o)
131}
132
133/// Derives [`LayerTransform3dMatrix`] from this frame's layout for every
134/// promoted root carrying [`LayerTransform3d`], and pushes composite-only dirt
135/// on change. Runs in `PostUpdate` after `sync_layer_geometry` (needs final
136/// layout) and before `resolve_layer_repaints` (which drains the dirt).
137///
138/// This is the **single dirt choke point** for the transform: the style
139/// applier, transitions, and animation bindings all just write the params
140/// component; whatever actually changed the matrix lands here once.
141#[allow(clippy::type_complexity)]
142pub fn sync_transform3d_matrices(
143    mut commands: Commands,
144    roots: Query<
145        (
146            Entity,
147            &ComputedNode,
148            &UiGlobalTransform,
149            &LayerTransform3d,
150            Option<&LayerTransform3dMatrix>,
151            &crate::bridge::RNode,
152        ),
153        With<PromotedLayer>,
154    >,
155    mut dirt: ResMut<LayerContentDirt>,
156) {
157    for (entity, computed, transform, params, existing, rnode) in &roots {
158        let size = computed.size();
159        if size.x <= 0.5 || size.y <= 0.5 {
160            continue; // Not laid out yet / empty: no quad to transform.
161        }
162        let min = transform.translation - size * 0.5;
163        let scale_factor = 1.0 / computed.inverse_scale_factor();
164        let (_, origin_fallback) = resolve_origin(&params.0, size, scale_factor);
165        if origin_fallback {
166            let _diag = crate::diag::node_scope(rnode.0);
167            crate::diag::report(
168                "length",
169                "origin",
170                "transform3d origin supports px and % only; falling back to 50%",
171            );
172        }
173        let next = LayerTransform3dMatrix {
174            model: build_transform3d_matrix(&params.0, min, size, scale_factor),
175            identity: params.0.is_identity(),
176        };
177        if existing != Some(&next) {
178            commands.entity(entity).insert(next);
179            // Composite-only: the quad reshapes; the capture is untouched.
180            // Dirties the enclosing chain only (`resolve_layer_repaints`).
181            dirt.composite_only.push(entity);
182        }
183    }
184}
185
186/// Convenience for the wire params carried by a style, if any.
187pub fn style_transform3d(style: &Option<protocol::style::Style>) -> Option<Transform3d> {
188    style.as_ref().and_then(|s| s.transform3d.clone())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::protocol::transform::Transform3dOrigin;
195
196    fn deg(
197        v: f32,
198    ) -> Option<crate::protocol::animatable::Animatable<crate::protocol::units::Angle>> {
199        serde_json::from_value(serde_json::json!(v)).ok()
200    }
201
202    /// Static-wrap a scalar channel value.
203    fn st(v: f32) -> Option<crate::protocol::animatable::Animatable<f32>> {
204        Some(crate::protocol::animatable::Animatable::Static(v))
205    }
206
207    /// Static-wrap an origin axis.
208    fn ax(l: Length) -> crate::protocol::animatable::Animatable<Length> {
209        crate::protocol::animatable::Animatable::Static(l)
210    }
211
212    /// The resolved origin is the fixed point of the transform for any
213    /// rotation/scale combination (no translate/perspective).
214    #[test]
215    fn origin_is_the_fixed_point() {
216        let params = Transform3d {
217            rotate_z: deg(45.0),
218            rotate_y: deg(30.0),
219            scale: st(2.0),
220            origin: Some(Transform3dOrigin {
221                x: ax(Length::Px(10.0)),
222                y: ax(Length::Px(20.0)),
223            }),
224            ..Default::default()
225        };
226        let m =
227            build_transform3d_matrix(&params, Vec2::new(100.0, 200.0), Vec2::new(50.0, 50.0), 1.0);
228        let o = Vec3::new(110.0, 220.0, 0.0);
229        assert!(m.project_point3(o).abs_diff_eq(o, 1e-3));
230    }
231
232    /// `rotateY(90°)` turns the plane edge-on: every point's x collapses onto
233    /// the origin's x (orthographic).
234    #[test]
235    fn rotate_y_90_collapses_x() {
236        let params = Transform3d {
237            rotate_y: deg(90.0),
238            ..Default::default()
239        };
240        // Default origin = center: (150, 100).
241        let m = build_transform3d_matrix(
242            &params,
243            Vec2::new(100.0, 50.0),
244            Vec2::new(100.0, 100.0),
245            1.0,
246        );
247        let p = m.project_point3(Vec3::new(180.0, 60.0, 0.0));
248        assert!(
249            (p.x - 150.0).abs() < 1e-3,
250            "x collapsed to origin.x, got {}",
251            p.x
252        );
253        assert!((p.y - 60.0).abs() < 1e-3, "y untouched, got {}", p.y);
254    }
255
256    /// Self-perspective: a point pushed to `z = d/2` lands at `w = 0.5`, so
257    /// its offset from the origin doubles after the divide, and positive
258    /// `translateZ` magnifies.
259    #[test]
260    fn perspective_divide_magnifies_toward_viewer() {
261        let params = Transform3d {
262            perspective: st(100.0),
263            translate_z: st(50.0),
264            origin: Some(Transform3dOrigin {
265                x: ax(Length::Px(0.0)),
266                y: ax(Length::Px(0.0)),
267            }),
268            ..Default::default()
269        };
270        let m = build_transform3d_matrix(&params, Vec2::ZERO, Vec2::new(100.0, 100.0), 1.0);
271        let p = m.project_point3(Vec3::new(10.0, 6.0, 0.0));
272        assert!(p.xy().abs_diff_eq(Vec2::new(20.0, 12.0), 1e-3), "got {p}");
273        // And the raw homogeneous w is real (not flattened) — the composite
274        // shader depends on it for perspective-correct interpolation.
275        let raw = m * Vec4::new(10.0, 6.0, 0.0, 1.0);
276        assert!((raw.w - 0.5).abs() < 1e-4);
277    }
278
279    /// Per-axis scale overrides the uniform channel (2D precedence rule).
280    #[test]
281    fn per_axis_scale_overrides_uniform() {
282        let params = Transform3d {
283            scale: st(2.0),
284            scale_x: st(3.0),
285            origin: Some(Transform3dOrigin {
286                x: ax(Length::Px(0.0)),
287                y: ax(Length::Px(0.0)),
288            }),
289            ..Default::default()
290        };
291        let m = build_transform3d_matrix(&params, Vec2::ZERO, Vec2::new(10.0, 10.0), 1.0);
292        let p = m.project_point3(Vec3::new(1.0, 1.0, 0.0));
293        assert!(p.xy().abs_diff_eq(Vec2::new(3.0, 2.0), 1e-4));
294    }
295
296    /// Wire lengths are logical px: translation and origin offsets scale by
297    /// the node's scale factor; percent origins don't (already physical).
298    #[test]
299    fn scale_factor_converts_logical_lengths() {
300        let params = Transform3d {
301            translate_x: st(10.0),
302            origin: Some(Transform3dOrigin {
303                x: ax(Length::Px(5.0)),
304                y: ax(Length::Percent(50.0)),
305            }),
306            ..Default::default()
307        };
308        let (offset, warned) = resolve_origin(&params, Vec2::new(100.0, 100.0), 2.0);
309        assert!(!warned);
310        assert_eq!(offset, Vec2::new(10.0, 50.0));
311        let m = build_transform3d_matrix(&params, Vec2::ZERO, Vec2::new(100.0, 100.0), 2.0);
312        let p = m.project_point3(Vec3::ZERO);
313        assert!(p.xy().abs_diff_eq(Vec2::new(20.0, 0.0), 1e-4));
314    }
315
316    /// A params change on a promoted root rebuilds the matrix and pushes
317    /// composite-only dirt (never content dirt); a settled value pushes
318    /// nothing on re-run.
319    #[test]
320    fn sync_pushes_composite_only_dirt_on_change() {
321        use bevy::ecs::system::RunSystemOnce;
322        use bevy::math::Affine2;
323
324        let mut world = World::new();
325        world.init_resource::<LayerContentDirt>();
326        let root = world
327            .spawn((
328                ComputedNode {
329                    size: Vec2::new(100.0, 50.0),
330                    ..Default::default()
331                },
332                UiGlobalTransform::from(Affine2::from_translation(Vec2::new(200.0, 100.0))),
333                LayerTransform3d(Transform3d {
334                    rotate_y: deg(30.0),
335                    ..Default::default()
336                }),
337                PromotedLayer {
338                    reasons: super::super::PromotionReasons(
339                        super::super::PromotionReasons::TRANSFORM3D,
340                    ),
341                },
342                crate::bridge::RNode(7),
343            ))
344            .id();
345
346        world.run_system_once(sync_transform3d_matrices).unwrap();
347        let dirt = world.resource::<LayerContentDirt>();
348        assert_eq!(dirt.composite_only, vec![root], "first build dirties");
349        assert!(dirt.nodes.is_empty(), "never content dirt");
350        let matrix = world.get::<LayerTransform3dMatrix>(root).expect("derived");
351        assert!(!matrix.identity);
352
353        // Settled: same params + geometry → no new dirt, component untouched.
354        world
355            .resource_mut::<LayerContentDirt>()
356            .composite_only
357            .clear();
358        world.run_system_once(sync_transform3d_matrices).unwrap();
359        assert!(
360            world
361                .resource::<LayerContentDirt>()
362                .composite_only
363                .is_empty()
364        );
365
366        // Param change → rebuild + dirt again.
367        world.get_mut::<LayerTransform3d>(root).unwrap().0.rotate_y = deg(60.0);
368        world.run_system_once(sync_transform3d_matrices).unwrap();
369        assert_eq!(
370            world.resource::<LayerContentDirt>().composite_only,
371            vec![root]
372        );
373    }
374
375    /// Unsupported origin units fall back to center and flag for the diag
376    /// report; a non-positive perspective is orthographic.
377    #[test]
378    fn origin_fallback_and_bad_perspective() {
379        let params = Transform3d {
380            origin: Some(Transform3dOrigin {
381                x: ax(Length::Auto),
382                y: ax(Length::Px(0.0)),
383            }),
384            perspective: st(0.0),
385            translate_z: st(50.0),
386            ..Default::default()
387        };
388        let (offset, warned) = resolve_origin(&params, Vec2::new(80.0, 60.0), 1.0);
389        assert!(warned);
390        assert_eq!(offset.x, 40.0);
391        let m = build_transform3d_matrix(&params, Vec2::ZERO, Vec2::new(80.0, 60.0), 1.0);
392        // Orthographic: translateZ has no x/y effect, w stays 1.
393        let raw = m * Vec4::new(10.0, 10.0, 0.0, 1.0);
394        assert_eq!(raw.w, 1.0);
395    }
396}