use std::sync::Arc;
use crate::animation::Animation;
use crate::duration::Lifetime;
use crate::id::ObjectId;
pub use oxideav_core::PixelFormat;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Canvas {
Raster {
width: u32,
height: u32,
pixel_format: PixelFormat,
},
Vector {
width: f32,
height: f32,
unit: LengthUnit,
},
}
impl Canvas {
pub const fn raster(width: u32, height: u32) -> Self {
Canvas::Raster {
width,
height,
pixel_format: PixelFormat::Yuv420P,
}
}
pub fn raster_size(&self) -> Option<(u32, u32)> {
match self {
Canvas::Raster { width, height, .. } => Some((*width, *height)),
Canvas::Vector { .. } => None,
}
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LengthUnit {
#[default]
Point,
Millimetre,
Inch,
CssPixel,
DevicePixel,
}
#[derive(Clone, Debug)]
pub struct SceneObject {
pub id: ObjectId,
pub kind: ObjectKind,
pub transform: Transform,
pub lifetime: Lifetime,
pub animations: Vec<Animation>,
pub z_order: i32,
pub opacity: f32,
pub blend_mode: BlendMode,
pub effects: Vec<Effect>,
pub clip: Option<ClipRect>,
}
impl Default for SceneObject {
fn default() -> Self {
SceneObject {
id: ObjectId::default(),
kind: ObjectKind::Shape(Shape::rect(0.0, 0.0)),
transform: Transform::identity(),
lifetime: Lifetime::default(),
animations: Vec::new(),
z_order: 0,
opacity: 1.0,
blend_mode: BlendMode::default(),
effects: Vec::new(),
clip: None,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum ObjectKind {
Image(ImageSource),
Video(VideoSource),
Text(TextRun),
Shape(Shape),
Group(Vec<ObjectId>),
Live(LiveStreamHandle),
Vector(oxideav_core::VectorFrame),
}
impl ObjectKind {
pub fn content_size(&self) -> Option<(f32, f32)> {
match self {
ObjectKind::Vector(vf) => Some((vf.width, vf.height)),
ObjectKind::Shape(s) => s.content_size(),
ObjectKind::Live(h) => h.hint_size.map(|(w, h)| (w as f32, h as f32)),
ObjectKind::Image(_)
| ObjectKind::Video(_)
| ObjectKind::Text(_)
| ObjectKind::Group(_) => None,
}
}
}
impl SceneObject {
pub fn content_size(&self) -> Option<(f32, f32)> {
self.kind.content_size()
}
pub fn bbox(&self, fallback: (f32, f32)) -> oxideav_core::Rect {
let (w, h) = self.content_size().unwrap_or(fallback);
let bb = self.transform.bbox(w, h);
match self.clip {
None => bb,
Some(clip) => intersect_rect(bb, clip),
}
}
pub fn evaluate_property_at(
&self,
t: crate::duration::TimeStamp,
prop: &crate::animation::AnimatedProperty,
) -> Option<crate::animation::KeyframeValue> {
let anim = self.animations.iter().find(|a| &a.property == prop)?;
anim.sample(t)
}
pub fn effective_transform_at(&self, t: crate::duration::TimeStamp) -> Transform {
use crate::animation::{AnimatedProperty as P, KeyframeValue as V};
let mut out = self.transform;
for prop in [P::Position, P::Scale, P::Rotation, P::Skew, P::Anchor] {
let Some(v) = self.evaluate_property_at(t, &prop) else {
continue;
};
match (prop, v) {
(P::Position, V::Vec2(dx, dy)) => {
out.position = (out.position.0 + dx, out.position.1 + dy);
}
(P::Scale, V::Vec2(sx, sy)) => {
out.scale = (out.scale.0 * sx, out.scale.1 * sy);
}
(P::Rotation, V::Scalar(r)) => {
out.rotation += r;
}
(P::Skew, V::Vec2(kx, ky)) => {
out.skew = (out.skew.0 + kx, out.skew.1 + ky);
}
(P::Anchor, V::Vec2(ax, ay)) => {
out.anchor = (ax, ay);
}
_ => {} }
}
out
}
pub fn effective_opacity_at(&self, t: crate::duration::TimeStamp) -> f32 {
use crate::animation::{AnimatedProperty as P, KeyframeValue as V};
let base = self.opacity;
let factor = match self.evaluate_property_at(t, &P::Opacity) {
Some(V::Scalar(v)) => v,
_ => 1.0,
};
(base * factor).clamp(0.0, 1.0)
}
pub fn sample_at(&self, t: crate::duration::TimeStamp) -> Sample {
Sample {
id: self.id,
z_order: self.z_order,
transform: self.effective_transform_at(t),
opacity: self.effective_opacity_at(t),
blend_mode: self.blend_mode,
clip: self.clip,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Sample {
pub id: ObjectId,
pub z_order: i32,
pub transform: Transform,
pub opacity: f32,
pub blend_mode: BlendMode,
pub clip: Option<ClipRect>,
}
fn intersect_rect(a: oxideav_core::Rect, clip: ClipRect) -> oxideav_core::Rect {
let ax2 = a.x + a.width;
let ay2 = a.y + a.height;
let bx1 = clip.x;
let by1 = clip.y;
let bx2 = clip.x + clip.width;
let by2 = clip.y + clip.height;
let x1 = a.x.max(bx1);
let y1 = a.y.max(by1);
let x2 = ax2.min(bx2);
let y2 = ay2.min(by2);
if x2 <= x1 || y2 <= y1 {
oxideav_core::Rect::new(x1, y1, 0.0, 0.0)
} else {
oxideav_core::Rect::new(x1, y1, x2 - x1, y2 - y1)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transform {
pub position: (f32, f32),
pub scale: (f32, f32),
pub rotation: f32,
pub anchor: (f32, f32),
pub skew: (f32, f32),
}
impl Transform {
pub const fn identity() -> Self {
Transform {
position: (0.0, 0.0),
scale: (1.0, 1.0),
rotation: 0.0,
anchor: (0.5, 0.5),
skew: (0.0, 0.0),
}
}
pub fn to_matrix(&self, width: f32, height: f32) -> oxideav_core::Transform2D {
use oxideav_core::Transform2D as M;
let (px, py) = (self.anchor.0 * width, self.anchor.1 * height);
let mut m = M::translate(self.position.0, self.position.1);
m = m.compose(&M::translate(px, py));
if self.skew.0 != 0.0 {
m = m.compose(&M::skew_x(self.skew.0));
}
if self.skew.1 != 0.0 {
m = m.compose(&M::skew_y(self.skew.1));
}
m = m.compose(&M::scale(self.scale.0, self.scale.1));
if self.rotation != 0.0 {
m = m.compose(&M::rotate(self.rotation));
}
m = m.compose(&M::translate(-px, -py));
m
}
pub fn apply_to_point(
&self,
width: f32,
height: f32,
point: oxideav_core::Point,
) -> oxideav_core::Point {
self.to_matrix(width, height).apply(point)
}
pub fn bbox(&self, width: f32, height: f32) -> oxideav_core::Rect {
use oxideav_core::Point;
let m = self.to_matrix(width, height);
let corners = [
m.apply(Point::new(0.0, 0.0)),
m.apply(Point::new(width, 0.0)),
m.apply(Point::new(width, height)),
m.apply(Point::new(0.0, height)),
];
let mut min_x = corners[0].x;
let mut min_y = corners[0].y;
let mut max_x = corners[0].x;
let mut max_y = corners[0].y;
for p in &corners[1..] {
min_x = min_x.min(p.x);
min_y = min_y.min(p.y);
max_x = max_x.max(p.x);
max_y = max_y.max(p.y);
}
oxideav_core::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y)
}
}
impl Default for Transform {
fn default() -> Self {
Transform::identity()
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BlendMode {
#[default]
Normal,
Multiply,
Screen,
Overlay,
Add,
Subtract,
Copy,
}
#[derive(Clone, Debug)]
pub struct Effect {
pub name: String,
pub params: Vec<(String, f32)>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClipRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum ImageSource {
Decoded(Arc<oxideav_core::VideoFrame>),
Path(String),
EncodedBytes(Arc<[u8]>),
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum VideoSource {
Path(String),
EncodedBytes(Arc<[u8]>),
}
#[derive(Clone, Debug, Default)]
pub struct TextRun {
pub text: String,
pub font_family: String,
pub font_weight: u16,
pub font_size: f32,
pub color: u32,
pub advances: Option<Vec<f32>>,
pub italic: bool,
pub underline: bool,
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum Shape {
Rect {
width: f32,
height: f32,
fill: u32,
stroke: Option<Stroke>,
corner_radius: f32,
},
Polygon {
points: Vec<(f32, f32)>,
fill: u32,
stroke: Option<Stroke>,
},
Path {
data: String,
fill: u32,
stroke: Option<Stroke>,
},
}
impl Shape {
pub const fn rect(width: f32, height: f32) -> Self {
Shape::Rect {
width,
height,
fill: 0,
stroke: None,
corner_radius: 0.0,
}
}
pub fn content_size(&self) -> Option<(f32, f32)> {
match self {
Shape::Rect { width, height, .. } => Some((*width, *height)),
Shape::Polygon { points, .. } => {
if points.is_empty() {
return Some((0.0, 0.0));
}
let (mut min_x, mut min_y) = points[0];
let (mut max_x, mut max_y) = (min_x, min_y);
for &(x, y) in &points[1..] {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
Some(((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)))
}
Shape::Path { data, .. } => {
crate::svg_path::parse_bbox(data).map(|(min_x, min_y, max_x, max_y)| {
((max_x - min_x).max(0.0), (max_y - min_y).max(0.0))
})
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stroke {
pub color: u32,
pub width: f32,
}
#[derive(Clone, Debug)]
pub struct LiveStreamHandle {
pub uri: String,
pub hint_size: Option<(u32, u32)>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raster_canvas_size() {
let c = Canvas::raster(640, 480);
assert_eq!(c.raster_size(), Some((640, 480)));
}
#[test]
fn vector_canvas_no_raster_size() {
let c = Canvas::Vector {
width: 595.0,
height: 842.0,
unit: LengthUnit::Point,
};
assert!(c.raster_size().is_none());
}
#[test]
fn transform_identity_roundtrip() {
let t = Transform::identity();
assert_eq!(t.position, (0.0, 0.0));
assert_eq!(t.scale, (1.0, 1.0));
assert_eq!(t.anchor, (0.5, 0.5));
}
#[test]
fn scene_object_default_is_neutral() {
let o = SceneObject::default();
assert_eq!(o.opacity, 1.0);
assert_eq!(o.blend_mode, BlendMode::Normal);
assert!(o.animations.is_empty());
}
#[test]
fn identity_transform_lowers_to_identity_matrix() {
let m = Transform::identity().to_matrix(100.0, 50.0);
assert!(m.is_identity());
}
#[test]
fn translate_only_offsets_points() {
let t = Transform {
position: (10.0, -5.0),
..Transform::identity()
};
let p = t.apply_to_point(40.0, 40.0, oxideav_core::Point::new(3.0, 7.0));
assert!((p.x - 13.0).abs() < 1e-5);
assert!((p.y - 2.0).abs() < 1e-5);
}
#[test]
fn scale_pivots_about_anchor_centre() {
let t = Transform {
scale: (2.0, 2.0),
..Transform::identity()
};
let centre = t.apply_to_point(20.0, 20.0, oxideav_core::Point::new(10.0, 10.0));
assert!((centre.x - 10.0).abs() < 1e-5);
assert!((centre.y - 10.0).abs() < 1e-5);
let bb = t.bbox(20.0, 20.0);
assert!((bb.width - 40.0).abs() < 1e-4);
assert!((bb.height - 40.0).abs() < 1e-4);
assert!((bb.x - (-10.0)).abs() < 1e-4);
assert!((bb.y - (-10.0)).abs() < 1e-4);
}
#[test]
fn quarter_turn_bbox_swaps_extent() {
let t = Transform {
rotation: std::f32::consts::FRAC_PI_2,
..Transform::identity()
};
let bb = t.bbox(40.0, 10.0);
assert!((bb.width - 10.0).abs() < 1e-3);
assert!((bb.height - 40.0).abs() < 1e-3);
}
#[test]
fn bbox_extent_is_never_negative() {
let t = Transform {
scale: (-3.0, 0.5),
rotation: 1.1,
skew: (0.3, -0.2),
position: (12.0, -4.0),
anchor: (0.25, 0.75),
};
let bb = t.bbox(30.0, 18.0);
assert!(bb.width >= 0.0);
assert!(bb.height >= 0.0);
}
#[test]
fn shape_rect_reports_its_own_extent() {
let s = Shape::Rect {
width: 80.0,
height: 30.0,
fill: 0,
stroke: None,
corner_radius: 4.0,
};
assert_eq!(s.content_size(), Some((80.0, 30.0)));
}
#[test]
fn shape_polygon_reports_aabb_of_points() {
let s = Shape::Polygon {
points: vec![(-3.0, 5.0), (10.0, -2.0), (7.0, 12.0)],
fill: 0,
stroke: None,
};
assert_eq!(s.content_size(), Some((13.0, 14.0)));
}
#[test]
fn empty_polygon_has_zero_extent() {
let s = Shape::Polygon {
points: Vec::new(),
fill: 0,
stroke: None,
};
assert_eq!(s.content_size(), Some((0.0, 0.0)));
}
#[test]
fn shape_path_extent_is_parsed_aabb() {
let s = Shape::Path {
data: "M10,10 L20,20".to_string(),
fill: 0,
stroke: None,
};
assert_eq!(s.content_size(), Some((10.0, 10.0)));
}
#[test]
fn shape_path_unparseable_returns_none() {
let s = Shape::Path {
data: "totally-not-a-path".to_string(),
fill: 0,
stroke: None,
};
assert!(s.content_size().is_none());
}
#[test]
fn shape_path_arc_returns_none_for_now() {
let s = Shape::Path {
data: "M0,0 A 5 5 0 0 0 10 10".to_string(),
fill: 0,
stroke: None,
};
assert!(s.content_size().is_none());
}
#[test]
fn live_kind_uses_hint_size_when_present() {
let live = ObjectKind::Live(LiveStreamHandle {
uri: "rtmp://x".into(),
hint_size: Some((1280, 720)),
});
assert_eq!(live.content_size(), Some((1280.0, 720.0)));
let live_blank = ObjectKind::Live(LiveStreamHandle {
uri: "rtmp://x".into(),
hint_size: None,
});
assert!(live_blank.content_size().is_none());
}
#[test]
fn vector_kind_pulls_extent_from_frame_viewport() {
let vf = oxideav_core::VectorFrame::new(640.0, 480.0);
let k = ObjectKind::Vector(vf);
assert_eq!(k.content_size(), Some((640.0, 480.0)));
}
#[test]
fn image_video_text_group_have_no_intrinsic_extent() {
assert!(ObjectKind::Text(TextRun::default())
.content_size()
.is_none());
assert!(ObjectKind::Group(Vec::new()).content_size().is_none());
}
#[test]
fn scene_object_bbox_uses_intrinsic_extent() {
let obj = SceneObject {
kind: ObjectKind::Shape(Shape::Rect {
width: 40.0,
height: 20.0,
fill: 0,
stroke: None,
corner_radius: 0.0,
}),
transform: Transform {
position: (5.0, 7.0),
..Transform::identity()
},
..SceneObject::default()
};
let bb = obj.bbox((1000.0, 1000.0));
assert!((bb.x - 5.0).abs() < 1e-4);
assert!((bb.y - 7.0).abs() < 1e-4);
assert!((bb.width - 40.0).abs() < 1e-4);
assert!((bb.height - 20.0).abs() < 1e-4);
}
#[test]
fn scene_object_bbox_falls_back_for_extentless_kinds() {
let obj = SceneObject {
kind: ObjectKind::Text(TextRun::default()),
transform: Transform {
position: (10.0, 20.0),
..Transform::identity()
},
..SceneObject::default()
};
let bb = obj.bbox((100.0, 50.0));
assert!((bb.x - 10.0).abs() < 1e-4);
assert!((bb.y - 20.0).abs() < 1e-4);
assert!((bb.width - 100.0).abs() < 1e-4);
assert!((bb.height - 50.0).abs() < 1e-4);
}
#[test]
fn scene_object_bbox_clips_to_clip_rect() {
let obj = SceneObject {
kind: ObjectKind::Shape(Shape::Rect {
width: 100.0,
height: 100.0,
fill: 0,
stroke: None,
corner_radius: 0.0,
}),
transform: Transform::identity(),
clip: Some(ClipRect {
x: 20.0,
y: 30.0,
width: 50.0,
height: 40.0,
}),
..SceneObject::default()
};
let bb = obj.bbox((0.0, 0.0));
assert!((bb.x - 20.0).abs() < 1e-4);
assert!((bb.y - 30.0).abs() < 1e-4);
assert!((bb.width - 50.0).abs() < 1e-4);
assert!((bb.height - 40.0).abs() < 1e-4);
}
#[test]
fn scene_object_bbox_clip_with_no_overlap_collapses_to_zero() {
let obj = SceneObject {
kind: ObjectKind::Shape(Shape::Rect {
width: 10.0,
height: 10.0,
fill: 0,
stroke: None,
corner_radius: 0.0,
}),
transform: Transform::identity(),
clip: Some(ClipRect {
x: 500.0,
y: 500.0,
width: 50.0,
height: 50.0,
}),
..SceneObject::default()
};
let bb = obj.bbox((0.0, 0.0));
assert!(bb.width <= 0.0 || bb.height <= 0.0);
}
use crate::animation::{
AnimatedProperty as P, Animation, Easing, Keyframe, KeyframeValue as V, Repeat,
};
fn scalar_anim(prop: P, kf: &[(crate::duration::TimeStamp, f32)]) -> Animation {
Animation::new(
prop,
kf.iter()
.map(|(t, v)| Keyframe {
time: *t,
value: V::Scalar(*v),
easing: None,
})
.collect(),
Easing::Linear,
Repeat::Once,
)
}
fn vec2_anim(prop: P, kf: &[(crate::duration::TimeStamp, (f32, f32))]) -> Animation {
Animation::new(
prop,
kf.iter()
.map(|(t, (x, y))| Keyframe {
time: *t,
value: V::Vec2(*x, *y),
easing: None,
})
.collect(),
Easing::Linear,
Repeat::Once,
)
}
#[test]
fn evaluate_property_at_returns_none_without_track() {
let obj = SceneObject::default();
assert!(obj.evaluate_property_at(0, &P::Opacity).is_none());
}
#[test]
fn evaluate_property_at_returns_raw_keyframe_value() {
let obj = SceneObject {
animations: vec![scalar_anim(P::Opacity, &[(0, 0.0), (100, 1.0)])],
..SceneObject::default()
};
let v = obj.evaluate_property_at(50, &P::Opacity).unwrap();
match v {
V::Scalar(s) => assert!((s - 0.5).abs() < 1e-4),
_ => panic!("wrong variant"),
}
}
#[test]
fn effective_transform_with_no_animation_is_base() {
let obj = SceneObject {
transform: Transform {
position: (10.0, 20.0),
scale: (2.0, 3.0),
rotation: 0.5,
anchor: (0.25, 0.75),
skew: (0.1, 0.2),
},
..SceneObject::default()
};
assert_eq!(obj.effective_transform_at(123), obj.transform);
}
#[test]
fn position_track_adds_to_base() {
let obj = SceneObject {
transform: Transform {
position: (5.0, 7.0),
..Transform::identity()
},
animations: vec![vec2_anim(
P::Position,
&[(0, (10.0, 20.0)), (100, (10.0, 20.0))],
)],
..SceneObject::default()
};
let t = obj.effective_transform_at(50);
assert!((t.position.0 - 15.0).abs() < 1e-4);
assert!((t.position.1 - 27.0).abs() < 1e-4);
}
#[test]
fn scale_track_multiplies_with_base() {
let obj = SceneObject {
transform: Transform {
scale: (2.0, 3.0),
..Transform::identity()
},
animations: vec![vec2_anim(P::Scale, &[(0, (1.5, 2.0)), (100, (1.5, 2.0))])],
..SceneObject::default()
};
let t = obj.effective_transform_at(50);
assert!((t.scale.0 - 3.0).abs() < 1e-4);
assert!((t.scale.1 - 6.0).abs() < 1e-4);
}
#[test]
fn rotation_track_adds_to_base() {
let obj = SceneObject {
transform: Transform {
rotation: 1.0,
..Transform::identity()
},
animations: vec![scalar_anim(P::Rotation, &[(0, 0.5), (100, 0.5)])],
..SceneObject::default()
};
assert!((obj.effective_transform_at(50).rotation - 1.5).abs() < 1e-4);
}
#[test]
fn skew_track_adds_to_base() {
let obj = SceneObject {
transform: Transform {
skew: (0.2, 0.3),
..Transform::identity()
},
animations: vec![vec2_anim(P::Skew, &[(0, (0.1, -0.1)), (100, (0.1, -0.1))])],
..SceneObject::default()
};
let t = obj.effective_transform_at(50);
assert!((t.skew.0 - 0.3).abs() < 1e-4);
assert!((t.skew.1 - 0.2).abs() < 1e-4);
}
#[test]
fn anchor_track_replaces_base() {
let obj = SceneObject {
transform: Transform {
anchor: (0.5, 0.5),
..Transform::identity()
},
animations: vec![vec2_anim(
P::Anchor,
&[(0, (0.25, 0.75)), (100, (0.25, 0.75))],
)],
..SceneObject::default()
};
let t = obj.effective_transform_at(50);
assert!((t.anchor.0 - 0.25).abs() < 1e-4);
assert!((t.anchor.1 - 0.75).abs() < 1e-4);
}
#[test]
fn variant_mismatch_on_transform_track_falls_through() {
let obj = SceneObject {
transform: Transform {
position: (3.0, 4.0),
..Transform::identity()
},
animations: vec![scalar_anim(P::Position, &[(0, 99.0), (100, 99.0)])],
..SceneObject::default()
};
let t = obj.effective_transform_at(50);
assert!((t.position.0 - 3.0).abs() < 1e-4);
assert!((t.position.1 - 4.0).abs() < 1e-4);
}
#[test]
fn effective_opacity_no_track_is_base() {
let obj = SceneObject {
opacity: 0.7,
..SceneObject::default()
};
assert!((obj.effective_opacity_at(0) - 0.7).abs() < 1e-4);
}
#[test]
fn effective_opacity_multiplies_and_clamps() {
let obj = SceneObject {
opacity: 0.8,
animations: vec![scalar_anim(P::Opacity, &[(0, 0.5), (100, 0.5)])],
..SceneObject::default()
};
assert!((obj.effective_opacity_at(50) - 0.4).abs() < 1e-4);
}
#[test]
fn effective_opacity_clamps_to_unit_range() {
let obj = SceneObject {
opacity: 1.0,
animations: vec![scalar_anim(P::Opacity, &[(0, 2.0), (100, 2.0)])],
..SceneObject::default()
};
assert!((obj.effective_opacity_at(50) - 1.0).abs() < 1e-4);
let obj = SceneObject {
opacity: 0.5,
animations: vec![scalar_anim(P::Opacity, &[(0, -1.0), (100, -1.0)])],
..SceneObject::default()
};
assert!(obj.effective_opacity_at(50).abs() < 1e-4);
}
#[test]
fn sample_at_forwards_compositor_fields() {
let obj = SceneObject {
id: ObjectId::new(42),
opacity: 0.5,
z_order: 7,
blend_mode: BlendMode::Screen,
clip: Some(ClipRect {
x: 1.0,
y: 2.0,
width: 3.0,
height: 4.0,
}),
transform: Transform {
position: (10.0, 20.0),
..Transform::identity()
},
animations: vec![scalar_anim(P::Opacity, &[(0, 0.5), (100, 0.5)])],
..SceneObject::default()
};
let s = obj.sample_at(50);
assert_eq!(s.id, ObjectId::new(42));
assert_eq!(s.z_order, 7);
assert_eq!(s.blend_mode, BlendMode::Screen);
assert!(s.clip.is_some());
assert!((s.opacity - 0.25).abs() < 1e-4); assert!((s.transform.position.0 - 10.0).abs() < 1e-4);
}
#[test]
fn multiple_transform_tracks_compose_independently() {
let obj = SceneObject {
transform: Transform {
position: (1.0, 1.0),
scale: (1.0, 1.0),
rotation: 0.1,
..Transform::identity()
},
animations: vec![
vec2_anim(P::Position, &[(0, (4.0, 5.0)), (100, (4.0, 5.0))]),
scalar_anim(P::Rotation, &[(0, 0.4), (100, 0.4)]),
vec2_anim(P::Scale, &[(0, (3.0, 4.0)), (100, (3.0, 4.0))]),
],
..SceneObject::default()
};
let t = obj.effective_transform_at(50);
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); }
}