1use std::sync::Arc;
4
5use crate::animation::Animation;
6use crate::duration::Lifetime;
7use crate::id::ObjectId;
8
9pub use oxideav_core::PixelFormat;
13
14#[non_exhaustive]
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum Canvas {
19 Raster {
22 width: u32,
23 height: u32,
24 pixel_format: PixelFormat,
25 },
26 Vector {
30 width: f32,
31 height: f32,
32 unit: LengthUnit,
33 },
34}
35
36impl Canvas {
37 pub const fn raster(width: u32, height: u32) -> Self {
39 Canvas::Raster {
40 width,
41 height,
42 pixel_format: PixelFormat::Yuv420P,
43 }
44 }
45
46 pub fn raster_size(&self) -> Option<(u32, u32)> {
48 match self {
49 Canvas::Raster { width, height, .. } => Some((*width, *height)),
50 Canvas::Vector { .. } => None,
51 }
52 }
53}
54
55#[non_exhaustive]
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub enum LengthUnit {
59 #[default]
61 Point,
62 Millimetre,
64 Inch,
66 CssPixel,
68 DevicePixel,
70}
71
72#[derive(Clone, Debug)]
74pub struct SceneObject {
75 pub id: ObjectId,
76 pub kind: ObjectKind,
77 pub transform: Transform,
78 pub lifetime: Lifetime,
79 pub animations: Vec<Animation>,
80 pub z_order: i32,
81 pub opacity: f32,
82 pub blend_mode: BlendMode,
83 pub effects: Vec<Effect>,
84 pub clip: Option<ClipRect>,
85}
86
87impl Default for SceneObject {
88 fn default() -> Self {
89 SceneObject {
90 id: ObjectId::default(),
91 kind: ObjectKind::Shape(Shape::rect(0.0, 0.0)),
92 transform: Transform::identity(),
93 lifetime: Lifetime::default(),
94 animations: Vec::new(),
95 z_order: 0,
96 opacity: 1.0,
97 blend_mode: BlendMode::default(),
98 effects: Vec::new(),
99 clip: None,
100 }
101 }
102}
103
104#[non_exhaustive]
106#[derive(Clone, Debug)]
107pub enum ObjectKind {
108 Image(ImageSource),
109 Video(VideoSource),
110 Text(TextRun),
111 Shape(Shape),
112 Group(Vec<ObjectId>),
113 Live(LiveStreamHandle),
114 Vector(oxideav_core::VectorFrame),
123}
124
125impl ObjectKind {
126 pub fn content_size(&self) -> Option<(f32, f32)> {
145 match self {
146 ObjectKind::Vector(vf) => Some((vf.width, vf.height)),
147 ObjectKind::Shape(s) => s.content_size(),
148 ObjectKind::Live(h) => h.hint_size.map(|(w, h)| (w as f32, h as f32)),
149 ObjectKind::Image(_)
150 | ObjectKind::Video(_)
151 | ObjectKind::Text(_)
152 | ObjectKind::Group(_) => None,
153 }
154 }
155}
156
157impl SceneObject {
158 pub fn content_size(&self) -> Option<(f32, f32)> {
163 self.kind.content_size()
164 }
165
166 pub fn bbox(&self, fallback: (f32, f32)) -> oxideav_core::Rect {
186 let (w, h) = self.content_size().unwrap_or(fallback);
187 let bb = self.transform.bbox(w, h);
188 match self.clip {
189 None => bb,
190 Some(clip) => intersect_rect(bb, clip),
191 }
192 }
193
194 pub fn evaluate_property_at(
210 &self,
211 t: crate::duration::TimeStamp,
212 prop: &crate::animation::AnimatedProperty,
213 ) -> Option<crate::animation::KeyframeValue> {
214 let anim = self.animations.iter().find(|a| &a.property == prop)?;
215 anim.sample(t)
216 }
217
218 pub fn effective_transform_at(&self, t: crate::duration::TimeStamp) -> Transform {
247 use crate::animation::{AnimatedProperty as P, KeyframeValue as V};
248 let mut out = self.transform;
249 for prop in [P::Position, P::Scale, P::Rotation, P::Skew, P::Anchor] {
250 let Some(v) = self.evaluate_property_at(t, &prop) else {
251 continue;
252 };
253 match (prop, v) {
254 (P::Position, V::Vec2(dx, dy)) => {
255 out.position = (out.position.0 + dx, out.position.1 + dy);
256 }
257 (P::Scale, V::Vec2(sx, sy)) => {
258 out.scale = (out.scale.0 * sx, out.scale.1 * sy);
259 }
260 (P::Rotation, V::Scalar(r)) => {
261 out.rotation += r;
262 }
263 (P::Skew, V::Vec2(kx, ky)) => {
264 out.skew = (out.skew.0 + kx, out.skew.1 + ky);
265 }
266 (P::Anchor, V::Vec2(ax, ay)) => {
267 out.anchor = (ax, ay);
268 }
269 _ => {} }
271 }
272 out
273 }
274
275 pub fn effective_opacity_at(&self, t: crate::duration::TimeStamp) -> f32 {
287 use crate::animation::{AnimatedProperty as P, KeyframeValue as V};
288 let base = self.opacity;
289 let factor = match self.evaluate_property_at(t, &P::Opacity) {
290 Some(V::Scalar(v)) => v,
291 _ => 1.0,
292 };
293 (base * factor).clamp(0.0, 1.0)
294 }
295
296 pub fn sample_at(&self, t: crate::duration::TimeStamp) -> Sample {
305 Sample {
306 id: self.id,
307 z_order: self.z_order,
308 transform: self.effective_transform_at(t),
309 opacity: self.effective_opacity_at(t),
310 blend_mode: self.blend_mode,
311 clip: self.clip,
312 }
313 }
314}
315
316#[derive(Clone, Copy, Debug)]
332pub struct Sample {
333 pub id: ObjectId,
334 pub z_order: i32,
335 pub transform: Transform,
336 pub opacity: f32,
337 pub blend_mode: BlendMode,
338 pub clip: Option<ClipRect>,
339}
340
341fn intersect_rect(a: oxideav_core::Rect, clip: ClipRect) -> oxideav_core::Rect {
345 let ax2 = a.x + a.width;
346 let ay2 = a.y + a.height;
347 let bx1 = clip.x;
348 let by1 = clip.y;
349 let bx2 = clip.x + clip.width;
350 let by2 = clip.y + clip.height;
351 let x1 = a.x.max(bx1);
352 let y1 = a.y.max(by1);
353 let x2 = ax2.min(bx2);
354 let y2 = ay2.min(by2);
355 if x2 <= x1 || y2 <= y1 {
356 oxideav_core::Rect::new(x1, y1, 0.0, 0.0)
357 } else {
358 oxideav_core::Rect::new(x1, y1, x2 - x1, y2 - y1)
359 }
360}
361
362#[derive(Clone, Copy, Debug, PartialEq)]
365pub struct Transform {
366 pub position: (f32, f32),
367 pub scale: (f32, f32),
368 pub rotation: f32,
370 pub anchor: (f32, f32),
373 pub skew: (f32, f32),
375}
376
377impl Transform {
378 pub const fn identity() -> Self {
379 Transform {
380 position: (0.0, 0.0),
381 scale: (1.0, 1.0),
382 rotation: 0.0,
383 anchor: (0.5, 0.5),
384 skew: (0.0, 0.0),
385 }
386 }
387
388 pub fn to_matrix(&self, width: f32, height: f32) -> oxideav_core::Transform2D {
413 use oxideav_core::Transform2D as M;
414
415 let (px, py) = (self.anchor.0 * width, self.anchor.1 * height);
416
417 let mut m = M::translate(self.position.0, self.position.1);
421 m = m.compose(&M::translate(px, py));
422 if self.skew.0 != 0.0 {
424 m = m.compose(&M::skew_x(self.skew.0));
425 }
426 if self.skew.1 != 0.0 {
427 m = m.compose(&M::skew_y(self.skew.1));
428 }
429 m = m.compose(&M::scale(self.scale.0, self.scale.1));
430 if self.rotation != 0.0 {
431 m = m.compose(&M::rotate(self.rotation));
432 }
433 m = m.compose(&M::translate(-px, -py));
434 m
435 }
436
437 pub fn apply_to_point(
442 &self,
443 width: f32,
444 height: f32,
445 point: oxideav_core::Point,
446 ) -> oxideav_core::Point {
447 self.to_matrix(width, height).apply(point)
448 }
449
450 pub fn bbox(&self, width: f32, height: f32) -> oxideav_core::Rect {
461 use oxideav_core::Point;
462
463 let m = self.to_matrix(width, height);
464 let corners = [
465 m.apply(Point::new(0.0, 0.0)),
466 m.apply(Point::new(width, 0.0)),
467 m.apply(Point::new(width, height)),
468 m.apply(Point::new(0.0, height)),
469 ];
470 let mut min_x = corners[0].x;
471 let mut min_y = corners[0].y;
472 let mut max_x = corners[0].x;
473 let mut max_y = corners[0].y;
474 for p in &corners[1..] {
475 min_x = min_x.min(p.x);
476 min_y = min_y.min(p.y);
477 max_x = max_x.max(p.x);
478 max_y = max_y.max(p.y);
479 }
480 oxideav_core::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y)
481 }
482}
483
484impl Default for Transform {
485 fn default() -> Self {
486 Transform::identity()
487 }
488}
489
490#[non_exhaustive]
492#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
493pub enum BlendMode {
494 #[default]
495 Normal,
496 Multiply,
497 Screen,
498 Overlay,
499 Add,
500 Subtract,
502 Copy,
505}
506
507#[derive(Clone, Debug)]
511pub struct Effect {
512 pub name: String,
513 pub params: Vec<(String, f32)>,
514}
515
516#[derive(Clone, Copy, Debug, PartialEq)]
518pub struct ClipRect {
519 pub x: f32,
520 pub y: f32,
521 pub width: f32,
522 pub height: f32,
523}
524
525#[non_exhaustive]
528#[derive(Clone, Debug)]
529pub enum ImageSource {
530 Decoded(Arc<oxideav_core::VideoFrame>),
532 Path(String),
534 EncodedBytes(Arc<[u8]>),
536}
537
538#[non_exhaustive]
541#[derive(Clone, Debug)]
542pub enum VideoSource {
543 Path(String),
544 EncodedBytes(Arc<[u8]>),
545}
546
547#[derive(Clone, Debug, Default)]
551pub struct TextRun {
552 pub text: String,
553 pub font_family: String,
554 pub font_weight: u16,
555 pub font_size: f32,
556 pub color: u32,
558 pub advances: Option<Vec<f32>>,
561 pub italic: bool,
562 pub underline: bool,
563}
564
565#[non_exhaustive]
567#[derive(Clone, Debug)]
568pub enum Shape {
569 Rect {
570 width: f32,
571 height: f32,
572 fill: u32,
573 stroke: Option<Stroke>,
574 corner_radius: f32,
575 },
576 Polygon {
577 points: Vec<(f32, f32)>,
578 fill: u32,
579 stroke: Option<Stroke>,
580 },
581 Path {
582 data: String,
584 fill: u32,
585 stroke: Option<Stroke>,
586 },
587}
588
589impl Shape {
590 pub const fn rect(width: f32, height: f32) -> Self {
593 Shape::Rect {
594 width,
595 height,
596 fill: 0,
597 stroke: None,
598 corner_radius: 0.0,
599 }
600 }
601
602 pub fn content_size(&self) -> Option<(f32, f32)> {
624 match self {
625 Shape::Rect { width, height, .. } => Some((*width, *height)),
626 Shape::Polygon { points, .. } => {
627 if points.is_empty() {
628 return Some((0.0, 0.0));
629 }
630 let (mut min_x, mut min_y) = points[0];
631 let (mut max_x, mut max_y) = (min_x, min_y);
632 for &(x, y) in &points[1..] {
633 min_x = min_x.min(x);
634 min_y = min_y.min(y);
635 max_x = max_x.max(x);
636 max_y = max_y.max(y);
637 }
638 Some(((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)))
639 }
640 Shape::Path { data, .. } => {
641 crate::svg_path::parse_bbox(data).map(|(min_x, min_y, max_x, max_y)| {
642 ((max_x - min_x).max(0.0), (max_y - min_y).max(0.0))
643 })
644 }
645 }
646 }
647}
648
649#[derive(Clone, Copy, Debug, PartialEq)]
650pub struct Stroke {
651 pub color: u32,
652 pub width: f32,
653}
654
655#[derive(Clone, Debug)]
658pub struct LiveStreamHandle {
659 pub uri: String,
663 pub hint_size: Option<(u32, u32)>,
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 #[test]
673 fn raster_canvas_size() {
674 let c = Canvas::raster(640, 480);
675 assert_eq!(c.raster_size(), Some((640, 480)));
676 }
677
678 #[test]
679 fn vector_canvas_no_raster_size() {
680 let c = Canvas::Vector {
681 width: 595.0,
682 height: 842.0,
683 unit: LengthUnit::Point,
684 };
685 assert!(c.raster_size().is_none());
686 }
687
688 #[test]
689 fn transform_identity_roundtrip() {
690 let t = Transform::identity();
691 assert_eq!(t.position, (0.0, 0.0));
692 assert_eq!(t.scale, (1.0, 1.0));
693 assert_eq!(t.anchor, (0.5, 0.5));
694 }
695
696 #[test]
697 fn scene_object_default_is_neutral() {
698 let o = SceneObject::default();
699 assert_eq!(o.opacity, 1.0);
700 assert_eq!(o.blend_mode, BlendMode::Normal);
701 assert!(o.animations.is_empty());
702 }
703
704 #[test]
705 fn identity_transform_lowers_to_identity_matrix() {
706 let m = Transform::identity().to_matrix(100.0, 50.0);
707 assert!(m.is_identity());
708 }
709
710 #[test]
711 fn translate_only_offsets_points() {
712 let t = Transform {
713 position: (10.0, -5.0),
714 ..Transform::identity()
715 };
716 let p = t.apply_to_point(40.0, 40.0, oxideav_core::Point::new(3.0, 7.0));
718 assert!((p.x - 13.0).abs() < 1e-5);
719 assert!((p.y - 2.0).abs() < 1e-5);
720 }
721
722 #[test]
723 fn scale_pivots_about_anchor_centre() {
724 let t = Transform {
727 scale: (2.0, 2.0),
728 ..Transform::identity()
729 };
730 let centre = t.apply_to_point(20.0, 20.0, oxideav_core::Point::new(10.0, 10.0));
731 assert!((centre.x - 10.0).abs() < 1e-5);
732 assert!((centre.y - 10.0).abs() < 1e-5);
733 let bb = t.bbox(20.0, 20.0);
734 assert!((bb.width - 40.0).abs() < 1e-4);
736 assert!((bb.height - 40.0).abs() < 1e-4);
737 assert!((bb.x - (-10.0)).abs() < 1e-4);
738 assert!((bb.y - (-10.0)).abs() < 1e-4);
739 }
740
741 #[test]
742 fn quarter_turn_bbox_swaps_extent() {
743 let t = Transform {
745 rotation: std::f32::consts::FRAC_PI_2,
746 ..Transform::identity()
747 };
748 let bb = t.bbox(40.0, 10.0);
749 assert!((bb.width - 10.0).abs() < 1e-3);
750 assert!((bb.height - 40.0).abs() < 1e-3);
751 }
752
753 #[test]
754 fn bbox_extent_is_never_negative() {
755 let t = Transform {
756 scale: (-3.0, 0.5),
757 rotation: 1.1,
758 skew: (0.3, -0.2),
759 position: (12.0, -4.0),
760 anchor: (0.25, 0.75),
761 };
762 let bb = t.bbox(30.0, 18.0);
763 assert!(bb.width >= 0.0);
764 assert!(bb.height >= 0.0);
765 }
766
767 #[test]
768 fn shape_rect_reports_its_own_extent() {
769 let s = Shape::Rect {
770 width: 80.0,
771 height: 30.0,
772 fill: 0,
773 stroke: None,
774 corner_radius: 4.0,
775 };
776 assert_eq!(s.content_size(), Some((80.0, 30.0)));
777 }
778
779 #[test]
780 fn shape_polygon_reports_aabb_of_points() {
781 let s = Shape::Polygon {
782 points: vec![(-3.0, 5.0), (10.0, -2.0), (7.0, 12.0)],
783 fill: 0,
784 stroke: None,
785 };
786 assert_eq!(s.content_size(), Some((13.0, 14.0)));
788 }
789
790 #[test]
791 fn empty_polygon_has_zero_extent() {
792 let s = Shape::Polygon {
793 points: Vec::new(),
794 fill: 0,
795 stroke: None,
796 };
797 assert_eq!(s.content_size(), Some((0.0, 0.0)));
798 }
799
800 #[test]
801 fn shape_path_extent_is_parsed_aabb() {
802 let s = Shape::Path {
803 data: "M10,10 L20,20".to_string(),
804 fill: 0,
805 stroke: None,
806 };
807 assert_eq!(s.content_size(), Some((10.0, 10.0)));
809 }
810
811 #[test]
812 fn shape_path_unparseable_returns_none() {
813 let s = Shape::Path {
814 data: "totally-not-a-path".to_string(),
815 fill: 0,
816 stroke: None,
817 };
818 assert!(s.content_size().is_none());
819 }
820
821 #[test]
822 fn shape_path_arc_returns_none_for_now() {
823 let s = Shape::Path {
826 data: "M0,0 A 5 5 0 0 0 10 10".to_string(),
827 fill: 0,
828 stroke: None,
829 };
830 assert!(s.content_size().is_none());
831 }
832
833 #[test]
834 fn live_kind_uses_hint_size_when_present() {
835 let live = ObjectKind::Live(LiveStreamHandle {
836 uri: "rtmp://x".into(),
837 hint_size: Some((1280, 720)),
838 });
839 assert_eq!(live.content_size(), Some((1280.0, 720.0)));
840 let live_blank = ObjectKind::Live(LiveStreamHandle {
841 uri: "rtmp://x".into(),
842 hint_size: None,
843 });
844 assert!(live_blank.content_size().is_none());
845 }
846
847 #[test]
848 fn vector_kind_pulls_extent_from_frame_viewport() {
849 let vf = oxideav_core::VectorFrame::new(640.0, 480.0);
850 let k = ObjectKind::Vector(vf);
851 assert_eq!(k.content_size(), Some((640.0, 480.0)));
852 }
853
854 #[test]
855 fn image_video_text_group_have_no_intrinsic_extent() {
856 assert!(ObjectKind::Text(TextRun::default())
857 .content_size()
858 .is_none());
859 assert!(ObjectKind::Group(Vec::new()).content_size().is_none());
860 }
861
862 #[test]
863 fn scene_object_bbox_uses_intrinsic_extent() {
864 let obj = SceneObject {
865 kind: ObjectKind::Shape(Shape::Rect {
866 width: 40.0,
867 height: 20.0,
868 fill: 0,
869 stroke: None,
870 corner_radius: 0.0,
871 }),
872 transform: Transform {
873 position: (5.0, 7.0),
874 ..Transform::identity()
875 },
876 ..SceneObject::default()
877 };
878 let bb = obj.bbox((1000.0, 1000.0));
880 assert!((bb.x - 5.0).abs() < 1e-4);
881 assert!((bb.y - 7.0).abs() < 1e-4);
882 assert!((bb.width - 40.0).abs() < 1e-4);
883 assert!((bb.height - 20.0).abs() < 1e-4);
884 }
885
886 #[test]
887 fn scene_object_bbox_falls_back_for_extentless_kinds() {
888 let obj = SceneObject {
889 kind: ObjectKind::Text(TextRun::default()),
890 transform: Transform {
891 position: (10.0, 20.0),
892 ..Transform::identity()
893 },
894 ..SceneObject::default()
895 };
896 let bb = obj.bbox((100.0, 50.0));
897 assert!((bb.x - 10.0).abs() < 1e-4);
898 assert!((bb.y - 20.0).abs() < 1e-4);
899 assert!((bb.width - 100.0).abs() < 1e-4);
900 assert!((bb.height - 50.0).abs() < 1e-4);
901 }
902
903 #[test]
904 fn scene_object_bbox_clips_to_clip_rect() {
905 let obj = SceneObject {
906 kind: ObjectKind::Shape(Shape::Rect {
907 width: 100.0,
908 height: 100.0,
909 fill: 0,
910 stroke: None,
911 corner_radius: 0.0,
912 }),
913 transform: Transform::identity(),
914 clip: Some(ClipRect {
915 x: 20.0,
916 y: 30.0,
917 width: 50.0,
918 height: 40.0,
919 }),
920 ..SceneObject::default()
921 };
922 let bb = obj.bbox((0.0, 0.0));
923 assert!((bb.x - 20.0).abs() < 1e-4);
924 assert!((bb.y - 30.0).abs() < 1e-4);
925 assert!((bb.width - 50.0).abs() < 1e-4);
926 assert!((bb.height - 40.0).abs() < 1e-4);
927 }
928
929 #[test]
930 fn scene_object_bbox_clip_with_no_overlap_collapses_to_zero() {
931 let obj = SceneObject {
932 kind: ObjectKind::Shape(Shape::Rect {
933 width: 10.0,
934 height: 10.0,
935 fill: 0,
936 stroke: None,
937 corner_radius: 0.0,
938 }),
939 transform: Transform::identity(),
940 clip: Some(ClipRect {
941 x: 500.0,
942 y: 500.0,
943 width: 50.0,
944 height: 50.0,
945 }),
946 ..SceneObject::default()
947 };
948 let bb = obj.bbox((0.0, 0.0));
949 assert!(bb.width <= 0.0 || bb.height <= 0.0);
950 }
951
952 use crate::animation::{
955 AnimatedProperty as P, Animation, Easing, Keyframe, KeyframeValue as V, Repeat,
956 };
957
958 fn scalar_anim(prop: P, kf: &[(crate::duration::TimeStamp, f32)]) -> Animation {
959 Animation::new(
960 prop,
961 kf.iter()
962 .map(|(t, v)| Keyframe {
963 time: *t,
964 value: V::Scalar(*v),
965 easing: None,
966 })
967 .collect(),
968 Easing::Linear,
969 Repeat::Once,
970 )
971 }
972
973 fn vec2_anim(prop: P, kf: &[(crate::duration::TimeStamp, (f32, f32))]) -> Animation {
974 Animation::new(
975 prop,
976 kf.iter()
977 .map(|(t, (x, y))| Keyframe {
978 time: *t,
979 value: V::Vec2(*x, *y),
980 easing: None,
981 })
982 .collect(),
983 Easing::Linear,
984 Repeat::Once,
985 )
986 }
987
988 #[test]
989 fn evaluate_property_at_returns_none_without_track() {
990 let obj = SceneObject::default();
991 assert!(obj.evaluate_property_at(0, &P::Opacity).is_none());
992 }
993
994 #[test]
995 fn evaluate_property_at_returns_raw_keyframe_value() {
996 let obj = SceneObject {
997 animations: vec![scalar_anim(P::Opacity, &[(0, 0.0), (100, 1.0)])],
998 ..SceneObject::default()
999 };
1000 let v = obj.evaluate_property_at(50, &P::Opacity).unwrap();
1001 match v {
1002 V::Scalar(s) => assert!((s - 0.5).abs() < 1e-4),
1003 _ => panic!("wrong variant"),
1004 }
1005 }
1006
1007 #[test]
1008 fn effective_transform_with_no_animation_is_base() {
1009 let obj = SceneObject {
1010 transform: Transform {
1011 position: (10.0, 20.0),
1012 scale: (2.0, 3.0),
1013 rotation: 0.5,
1014 anchor: (0.25, 0.75),
1015 skew: (0.1, 0.2),
1016 },
1017 ..SceneObject::default()
1018 };
1019 assert_eq!(obj.effective_transform_at(123), obj.transform);
1020 }
1021
1022 #[test]
1023 fn position_track_adds_to_base() {
1024 let obj = SceneObject {
1025 transform: Transform {
1026 position: (5.0, 7.0),
1027 ..Transform::identity()
1028 },
1029 animations: vec![vec2_anim(
1030 P::Position,
1031 &[(0, (10.0, 20.0)), (100, (10.0, 20.0))],
1032 )],
1033 ..SceneObject::default()
1034 };
1035 let t = obj.effective_transform_at(50);
1036 assert!((t.position.0 - 15.0).abs() < 1e-4);
1037 assert!((t.position.1 - 27.0).abs() < 1e-4);
1038 }
1039
1040 #[test]
1041 fn scale_track_multiplies_with_base() {
1042 let obj = SceneObject {
1043 transform: Transform {
1044 scale: (2.0, 3.0),
1045 ..Transform::identity()
1046 },
1047 animations: vec![vec2_anim(P::Scale, &[(0, (1.5, 2.0)), (100, (1.5, 2.0))])],
1048 ..SceneObject::default()
1049 };
1050 let t = obj.effective_transform_at(50);
1051 assert!((t.scale.0 - 3.0).abs() < 1e-4);
1052 assert!((t.scale.1 - 6.0).abs() < 1e-4);
1053 }
1054
1055 #[test]
1056 fn rotation_track_adds_to_base() {
1057 let obj = SceneObject {
1058 transform: Transform {
1059 rotation: 1.0,
1060 ..Transform::identity()
1061 },
1062 animations: vec![scalar_anim(P::Rotation, &[(0, 0.5), (100, 0.5)])],
1063 ..SceneObject::default()
1064 };
1065 assert!((obj.effective_transform_at(50).rotation - 1.5).abs() < 1e-4);
1066 }
1067
1068 #[test]
1069 fn skew_track_adds_to_base() {
1070 let obj = SceneObject {
1071 transform: Transform {
1072 skew: (0.2, 0.3),
1073 ..Transform::identity()
1074 },
1075 animations: vec![vec2_anim(P::Skew, &[(0, (0.1, -0.1)), (100, (0.1, -0.1))])],
1076 ..SceneObject::default()
1077 };
1078 let t = obj.effective_transform_at(50);
1079 assert!((t.skew.0 - 0.3).abs() < 1e-4);
1080 assert!((t.skew.1 - 0.2).abs() < 1e-4);
1081 }
1082
1083 #[test]
1084 fn anchor_track_replaces_base() {
1085 let obj = SceneObject {
1086 transform: Transform {
1087 anchor: (0.5, 0.5),
1088 ..Transform::identity()
1089 },
1090 animations: vec![vec2_anim(
1091 P::Anchor,
1092 &[(0, (0.25, 0.75)), (100, (0.25, 0.75))],
1093 )],
1094 ..SceneObject::default()
1095 };
1096 let t = obj.effective_transform_at(50);
1097 assert!((t.anchor.0 - 0.25).abs() < 1e-4);
1098 assert!((t.anchor.1 - 0.75).abs() < 1e-4);
1099 }
1100
1101 #[test]
1102 fn variant_mismatch_on_transform_track_falls_through() {
1103 let obj = SceneObject {
1105 transform: Transform {
1106 position: (3.0, 4.0),
1107 ..Transform::identity()
1108 },
1109 animations: vec![scalar_anim(P::Position, &[(0, 99.0), (100, 99.0)])],
1110 ..SceneObject::default()
1111 };
1112 let t = obj.effective_transform_at(50);
1113 assert!((t.position.0 - 3.0).abs() < 1e-4);
1114 assert!((t.position.1 - 4.0).abs() < 1e-4);
1115 }
1116
1117 #[test]
1118 fn effective_opacity_no_track_is_base() {
1119 let obj = SceneObject {
1120 opacity: 0.7,
1121 ..SceneObject::default()
1122 };
1123 assert!((obj.effective_opacity_at(0) - 0.7).abs() < 1e-4);
1124 }
1125
1126 #[test]
1127 fn effective_opacity_multiplies_and_clamps() {
1128 let obj = SceneObject {
1129 opacity: 0.8,
1130 animations: vec![scalar_anim(P::Opacity, &[(0, 0.5), (100, 0.5)])],
1131 ..SceneObject::default()
1132 };
1133 assert!((obj.effective_opacity_at(50) - 0.4).abs() < 1e-4);
1135 }
1136
1137 #[test]
1138 fn effective_opacity_clamps_to_unit_range() {
1139 let obj = SceneObject {
1141 opacity: 1.0,
1142 animations: vec![scalar_anim(P::Opacity, &[(0, 2.0), (100, 2.0)])],
1143 ..SceneObject::default()
1144 };
1145 assert!((obj.effective_opacity_at(50) - 1.0).abs() < 1e-4);
1146
1147 let obj = SceneObject {
1149 opacity: 0.5,
1150 animations: vec![scalar_anim(P::Opacity, &[(0, -1.0), (100, -1.0)])],
1151 ..SceneObject::default()
1152 };
1153 assert!(obj.effective_opacity_at(50).abs() < 1e-4);
1154 }
1155
1156 #[test]
1157 fn sample_at_forwards_compositor_fields() {
1158 let obj = SceneObject {
1159 id: ObjectId::new(42),
1160 opacity: 0.5,
1161 z_order: 7,
1162 blend_mode: BlendMode::Screen,
1163 clip: Some(ClipRect {
1164 x: 1.0,
1165 y: 2.0,
1166 width: 3.0,
1167 height: 4.0,
1168 }),
1169 transform: Transform {
1170 position: (10.0, 20.0),
1171 ..Transform::identity()
1172 },
1173 animations: vec![scalar_anim(P::Opacity, &[(0, 0.5), (100, 0.5)])],
1174 ..SceneObject::default()
1175 };
1176 let s = obj.sample_at(50);
1177 assert_eq!(s.id, ObjectId::new(42));
1178 assert_eq!(s.z_order, 7);
1179 assert_eq!(s.blend_mode, BlendMode::Screen);
1180 assert!(s.clip.is_some());
1181 assert!((s.opacity - 0.25).abs() < 1e-4); assert!((s.transform.position.0 - 10.0).abs() < 1e-4);
1183 }
1184
1185 #[test]
1186 fn multiple_transform_tracks_compose_independently() {
1187 let obj = SceneObject {
1188 transform: Transform {
1189 position: (1.0, 1.0),
1190 scale: (1.0, 1.0),
1191 rotation: 0.1,
1192 ..Transform::identity()
1193 },
1194 animations: vec![
1195 vec2_anim(P::Position, &[(0, (4.0, 5.0)), (100, (4.0, 5.0))]),
1196 scalar_anim(P::Rotation, &[(0, 0.4), (100, 0.4)]),
1197 vec2_anim(P::Scale, &[(0, (3.0, 4.0)), (100, (3.0, 4.0))]),
1198 ],
1199 ..SceneObject::default()
1200 };
1201 let t = obj.effective_transform_at(50);
1202 assert!((t.position.0 - 5.0).abs() < 1e-4); assert!((t.position.1 - 6.0).abs() < 1e-4); assert!((t.scale.0 - 3.0).abs() < 1e-4); assert!((t.scale.1 - 4.0).abs() < 1e-4); assert!((t.rotation - 0.5).abs() < 1e-4); }
1208}