1use bevy::prelude::*;
22use bevy::ui::{ComputedNode, UiGlobalTransform};
23
24use super::{LayerContentDirt, PromotedLayer};
25use crate::protocol::{self, AnimatableField, Length, Transform3d};
26
27#[derive(Component, Debug, Clone, PartialEq)]
34pub struct LayerTransform3d(pub Transform3d);
35
36#[derive(Component, Debug, Clone, Copy, PartialEq)]
42pub struct LayerTransform3dMatrix {
43 pub model: Mat4,
48 pub identity: bool,
52}
53
54fn 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
66pub fn resolve_origin(params: &Transform3d, size: Vec2, scale_factor: f32) -> (Vec2, bool) {
71 let origin = params.origin.clone().unwrap_or_default();
72 let axis = |a: &crate::protocol::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
82pub 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 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 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#[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; }
162 let min = transform.translation - size * 0.5;
163 let scale_factor = 1.0 / computed.inverse_scale_factor();
164 let (_, origin_fallback) = resolve_origin(¶ms.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(¶ms.0, min, size, scale_factor),
175 identity: params.0.is_identity(),
176 };
177 if existing != Some(&next) {
178 commands.entity(entity).insert(next);
179 dirt.composite_only.push(entity);
182 }
183 }
184}
185
186pub fn style_transform3d(style: &Option<protocol::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::Transform3dOrigin;
195
196 fn deg(v: f32) -> Option<crate::protocol::Animatable<crate::protocol::Angle>> {
197 serde_json::from_value(serde_json::json!(v)).ok()
198 }
199
200 fn st(v: f32) -> Option<crate::protocol::Animatable<f32>> {
202 Some(crate::protocol::Animatable::Static(v))
203 }
204
205 fn ax(l: Length) -> crate::protocol::Animatable<Length> {
207 crate::protocol::Animatable::Static(l)
208 }
209
210 #[test]
213 fn origin_is_the_fixed_point() {
214 let params = Transform3d {
215 rotate_z: deg(45.0),
216 rotate_y: deg(30.0),
217 scale: st(2.0),
218 origin: Some(Transform3dOrigin {
219 x: ax(Length::Px(10.0)),
220 y: ax(Length::Px(20.0)),
221 }),
222 ..Default::default()
223 };
224 let m =
225 build_transform3d_matrix(¶ms, Vec2::new(100.0, 200.0), Vec2::new(50.0, 50.0), 1.0);
226 let o = Vec3::new(110.0, 220.0, 0.0);
227 assert!(m.project_point3(o).abs_diff_eq(o, 1e-3));
228 }
229
230 #[test]
233 fn rotate_y_90_collapses_x() {
234 let params = Transform3d {
235 rotate_y: deg(90.0),
236 ..Default::default()
237 };
238 let m = build_transform3d_matrix(
240 ¶ms,
241 Vec2::new(100.0, 50.0),
242 Vec2::new(100.0, 100.0),
243 1.0,
244 );
245 let p = m.project_point3(Vec3::new(180.0, 60.0, 0.0));
246 assert!(
247 (p.x - 150.0).abs() < 1e-3,
248 "x collapsed to origin.x, got {}",
249 p.x
250 );
251 assert!((p.y - 60.0).abs() < 1e-3, "y untouched, got {}", p.y);
252 }
253
254 #[test]
258 fn perspective_divide_magnifies_toward_viewer() {
259 let params = Transform3d {
260 perspective: st(100.0),
261 translate_z: st(50.0),
262 origin: Some(Transform3dOrigin {
263 x: ax(Length::Px(0.0)),
264 y: ax(Length::Px(0.0)),
265 }),
266 ..Default::default()
267 };
268 let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(100.0, 100.0), 1.0);
269 let p = m.project_point3(Vec3::new(10.0, 6.0, 0.0));
270 assert!(p.xy().abs_diff_eq(Vec2::new(20.0, 12.0), 1e-3), "got {p}");
271 let raw = m * Vec4::new(10.0, 6.0, 0.0, 1.0);
274 assert!((raw.w - 0.5).abs() < 1e-4);
275 }
276
277 #[test]
279 fn per_axis_scale_overrides_uniform() {
280 let params = Transform3d {
281 scale: st(2.0),
282 scale_x: st(3.0),
283 origin: Some(Transform3dOrigin {
284 x: ax(Length::Px(0.0)),
285 y: ax(Length::Px(0.0)),
286 }),
287 ..Default::default()
288 };
289 let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(10.0, 10.0), 1.0);
290 let p = m.project_point3(Vec3::new(1.0, 1.0, 0.0));
291 assert!(p.xy().abs_diff_eq(Vec2::new(3.0, 2.0), 1e-4));
292 }
293
294 #[test]
297 fn scale_factor_converts_logical_lengths() {
298 let params = Transform3d {
299 translate_x: st(10.0),
300 origin: Some(Transform3dOrigin {
301 x: ax(Length::Px(5.0)),
302 y: ax(Length::Percent(50.0)),
303 }),
304 ..Default::default()
305 };
306 let (offset, warned) = resolve_origin(¶ms, Vec2::new(100.0, 100.0), 2.0);
307 assert!(!warned);
308 assert_eq!(offset, Vec2::new(10.0, 50.0));
309 let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(100.0, 100.0), 2.0);
310 let p = m.project_point3(Vec3::ZERO);
311 assert!(p.xy().abs_diff_eq(Vec2::new(20.0, 0.0), 1e-4));
312 }
313
314 #[test]
318 fn sync_pushes_composite_only_dirt_on_change() {
319 use bevy::ecs::system::RunSystemOnce;
320 use bevy::math::Affine2;
321
322 let mut world = World::new();
323 world.init_resource::<LayerContentDirt>();
324 let root = world
325 .spawn((
326 ComputedNode {
327 size: Vec2::new(100.0, 50.0),
328 ..Default::default()
329 },
330 UiGlobalTransform::from(Affine2::from_translation(Vec2::new(200.0, 100.0))),
331 LayerTransform3d(Transform3d {
332 rotate_y: deg(30.0),
333 ..Default::default()
334 }),
335 PromotedLayer {
336 reasons: super::super::PromotionReasons(
337 super::super::PromotionReasons::TRANSFORM3D,
338 ),
339 },
340 crate::bridge::RNode(7),
341 ))
342 .id();
343
344 world.run_system_once(sync_transform3d_matrices).unwrap();
345 let dirt = world.resource::<LayerContentDirt>();
346 assert_eq!(dirt.composite_only, vec![root], "first build dirties");
347 assert!(dirt.nodes.is_empty(), "never content dirt");
348 let matrix = world.get::<LayerTransform3dMatrix>(root).expect("derived");
349 assert!(!matrix.identity);
350
351 world
353 .resource_mut::<LayerContentDirt>()
354 .composite_only
355 .clear();
356 world.run_system_once(sync_transform3d_matrices).unwrap();
357 assert!(
358 world
359 .resource::<LayerContentDirt>()
360 .composite_only
361 .is_empty()
362 );
363
364 world.get_mut::<LayerTransform3d>(root).unwrap().0.rotate_y = deg(60.0);
366 world.run_system_once(sync_transform3d_matrices).unwrap();
367 assert_eq!(
368 world.resource::<LayerContentDirt>().composite_only,
369 vec![root]
370 );
371 }
372
373 #[test]
376 fn origin_fallback_and_bad_perspective() {
377 let params = Transform3d {
378 origin: Some(Transform3dOrigin {
379 x: ax(Length::Auto),
380 y: ax(Length::Px(0.0)),
381 }),
382 perspective: st(0.0),
383 translate_z: st(50.0),
384 ..Default::default()
385 };
386 let (offset, warned) = resolve_origin(¶ms, Vec2::new(80.0, 60.0), 1.0);
387 assert!(warned);
388 assert_eq!(offset.x, 40.0);
389 let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(80.0, 60.0), 1.0);
390 let raw = m * Vec4::new(10.0, 10.0, 0.0, 1.0);
392 assert_eq!(raw.w, 1.0);
393 }
394}