1use bevy::prelude::*;
22use bevy::ui::{ComputedNode, UiGlobalTransform};
23
24use super::{LayerContentDirt, PromotedLayer};
25use crate::protocol::{self, animatable::AnimatableField, transform::Transform3d, units::Length};
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::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::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 fn st(v: f32) -> Option<crate::protocol::animatable::Animatable<f32>> {
204 Some(crate::protocol::animatable::Animatable::Static(v))
205 }
206
207 fn ax(l: Length) -> crate::protocol::animatable::Animatable<Length> {
209 crate::protocol::animatable::Animatable::Static(l)
210 }
211
212 #[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(¶ms, 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 #[test]
235 fn rotate_y_90_collapses_x() {
236 let params = Transform3d {
237 rotate_y: deg(90.0),
238 ..Default::default()
239 };
240 let m = build_transform3d_matrix(
242 ¶ms,
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 #[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(¶ms, 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 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 #[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(¶ms, 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 #[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(¶ms, 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(¶ms, 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 #[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 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 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 #[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(¶ms, Vec2::new(80.0, 60.0), 1.0);
389 assert!(warned);
390 assert_eq!(offset.x, 40.0);
391 let m = build_transform3d_matrix(¶ms, Vec2::ZERO, Vec2::new(80.0, 60.0), 1.0);
392 let raw = m * Vec4::new(10.0, 10.0, 0.0, 1.0);
394 assert_eq!(raw.w, 1.0);
395 }
396}