1use crate::{
4 MotionEdge, MotionPoint, MotionPreference, MotionPx, MotionRect, motion_point, motion_rect,
5 motion_size, reveal_rect_from_edge,
6};
7
8const TRANSLATE_EPSILON: f32 = 0.01;
9const SCALE_EPSILON: f32 = 0.000_1;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
12struct MotionProjectionScale {
13 x: f32,
14 y: f32,
15}
16
17impl MotionProjectionScale {
18 const fn new(x: f32, y: f32) -> Self {
19 Self { x, y }
20 }
21
22 const fn x(self) -> f32 {
23 self.x
24 }
25
26 const fn y(self) -> f32 {
27 self.y
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct MotionProjection {
34 source: MotionRect,
35 target: MotionRect,
36}
37
38impl MotionProjection {
39 pub const fn between(source: MotionRect, target: MotionRect) -> Self {
41 Self { source, target }
42 }
43
44 pub const fn source(self) -> MotionRect {
46 self.source
47 }
48
49 pub const fn target(self) -> MotionRect {
51 self.target
52 }
53
54 pub fn visual_bounds(self, progress: f32) -> MotionRect {
56 self.visual_bounds_with_preference(progress, MotionPreference::Animated)
57 }
58
59 pub fn visual_bounds_with_preference(
61 self,
62 progress: f32,
63 preference: MotionPreference,
64 ) -> MotionRect {
65 self.sample_with_preference(progress, preference)
66 .visual_bounds()
67 }
68
69 fn sample_with_preference(
70 self,
71 progress: f32,
72 preference: MotionPreference,
73 ) -> MotionProjectionSample {
74 let progress = if preference.is_immediate() {
75 1.0
76 } else {
77 progress.clamp(0.0, 1.0)
78 };
79 let source_translation = self.source_translation();
80 let source_scale = self.source_scale();
81 let translation = motion_point(
82 MotionPx::new(source_translation.x.as_f32() * (1.0 - progress)),
83 MotionPx::new(source_translation.y.as_f32() * (1.0 - progress)),
84 );
85 let scale = MotionProjectionScale::new(
86 lerp_scale(source_scale.x(), 1.0, progress),
87 lerp_scale(source_scale.y(), 1.0, progress),
88 );
89
90 MotionProjectionSample::new(
91 self.target,
92 progress,
93 snap_translation(translation),
94 snap_scale(scale),
95 )
96 }
97
98 fn source_translation(self) -> MotionPoint {
99 snap_translation(motion_point(
100 self.source.origin.x - self.target.origin.x,
101 self.source.origin.y - self.target.origin.y,
102 ))
103 }
104
105 fn source_scale(self) -> MotionProjectionScale {
106 snap_scale(MotionProjectionScale::new(
107 divide_or_identity(self.source.size.width, self.target.size.width),
108 divide_or_identity(self.source.size.height, self.target.size.height),
109 ))
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
114struct MotionProjectionSample {
115 target_bounds: MotionRect,
116 progress: f32,
117 translation: MotionPoint,
118 scale: MotionProjectionScale,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct MotionProjectionClip {
124 content_bounds: MotionRect,
125 visible_bounds: MotionRect,
126 occlusion_bounds: MotionRect,
127 progress: f32,
128}
129
130impl MotionProjectionClip {
131 pub fn new(
133 content_bounds: MotionRect,
134 visible_bounds: MotionRect,
135 occlusion_bounds: MotionRect,
136 progress: f32,
137 ) -> Self {
138 Self {
139 content_bounds,
140 visible_bounds,
141 occlusion_bounds,
142 progress: progress.clamp(0.0, 1.0),
143 }
144 }
145
146 pub fn from_projection(projection: MotionProjection, progress: f32) -> Self {
148 Self::from_projection_with_preference(projection, progress, MotionPreference::Animated)
149 }
150
151 pub fn from_projection_with_preference(
153 projection: MotionProjection,
154 progress: f32,
155 preference: MotionPreference,
156 ) -> Self {
157 let sample = projection.sample_with_preference(progress, preference);
158 Self::new(
159 sample.target_bounds(),
160 sample.visual_bounds(),
161 sample.target_bounds(),
162 sample.progress(),
163 )
164 }
165
166 pub fn reveal(content_bounds: MotionRect, edge: MotionEdge, progress: f32) -> Self {
168 let progress = progress.clamp(0.0, 1.0);
169 Self::new(
170 content_bounds,
171 reveal_rect_from_edge(content_bounds, edge, progress),
172 content_bounds,
173 progress,
174 )
175 }
176
177 pub const fn content_bounds(self) -> MotionRect {
179 self.content_bounds
180 }
181
182 pub const fn visible_bounds(self) -> MotionRect {
184 self.visible_bounds
185 }
186
187 pub const fn occlusion_bounds(self) -> MotionRect {
189 self.occlusion_bounds
190 }
191
192 pub const fn progress(self) -> f32 {
194 self.progress
195 }
196}
197
198impl MotionProjectionSample {
199 const fn new(
200 target_bounds: MotionRect,
201 progress: f32,
202 translation: MotionPoint,
203 scale: MotionProjectionScale,
204 ) -> Self {
205 Self {
206 target_bounds,
207 progress,
208 translation,
209 scale,
210 }
211 }
212
213 const fn target_bounds(self) -> MotionRect {
214 self.target_bounds
215 }
216
217 fn visual_bounds(self) -> MotionRect {
218 motion_rect(
219 motion_point(
220 self.target_bounds.origin.x + self.translation.x,
221 self.target_bounds.origin.y + self.translation.y,
222 ),
223 motion_size(
224 self.target_bounds.size.width * self.scale.x(),
225 self.target_bounds.size.height * self.scale.y(),
226 ),
227 )
228 }
229
230 const fn progress(self) -> f32 {
231 self.progress
232 }
233}
234
235fn divide_or_identity(from: MotionPx, to: MotionPx) -> f32 {
236 let denominator = to.as_f32();
237 if denominator.abs() <= f32::EPSILON || !denominator.is_finite() {
238 1.0
239 } else {
240 sanitize_scale(from.as_f32() / denominator)
241 }
242}
243
244fn sanitize_scale(scale: f32) -> f32 {
245 if scale.is_finite() && scale > 0.0 {
246 scale
247 } else {
248 1.0
249 }
250}
251
252fn snap_translation(point: MotionPoint) -> MotionPoint {
253 motion_point(
254 MotionPx::new(snap_zero(point.x.as_f32(), TRANSLATE_EPSILON)),
255 MotionPx::new(snap_zero(point.y.as_f32(), TRANSLATE_EPSILON)),
256 )
257}
258
259fn snap_scale(scale: MotionProjectionScale) -> MotionProjectionScale {
260 MotionProjectionScale::new(
261 snap_one(scale.x(), SCALE_EPSILON),
262 snap_one(scale.y(), SCALE_EPSILON),
263 )
264}
265
266fn snap_zero(value: f32, epsilon: f32) -> f32 {
267 if value.abs() <= epsilon { 0.0 } else { value }
268}
269
270fn snap_one(value: f32, epsilon: f32) -> f32 {
271 if (value - 1.0).abs() <= epsilon {
272 1.0
273 } else {
274 value
275 }
276}
277
278fn lerp_scale(from: f32, to: f32, progress: f32) -> f32 {
279 from + (to - from) * progress
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::{MotionEdge, MotionPreference, MotionPx, motion_point, motion_rect, motion_size};
286
287 fn rect(x: f32, y: f32, width: f32, height: f32) -> crate::MotionRect {
288 motion_rect(
289 motion_point(MotionPx::new(x), MotionPx::new(y)),
290 motion_size(MotionPx::new(width), MotionPx::new(height)),
291 )
292 }
293
294 #[test]
295 fn projection_visual_bounds_move_without_mutating_target_bounds() {
296 let projection = MotionProjection::between(
297 rect(10.0, 20.0, 100.0, 50.0),
298 rect(30.0, 60.0, 200.0, 100.0),
299 );
300
301 assert_eq!(projection.target(), rect(30.0, 60.0, 200.0, 100.0));
302 assert_eq!(projection.visual_bounds(0.0), rect(10.0, 20.0, 100.0, 50.0));
303 assert_eq!(
304 projection.visual_bounds(1.0),
305 rect(30.0, 60.0, 200.0, 100.0)
306 );
307 }
308
309 #[test]
310 fn projection_clip_keeps_final_content_bounds_and_samples_visible_bounds() {
311 let projection = MotionProjection::between(
312 rect(10.0, 20.0, 100.0, 50.0),
313 rect(30.0, 60.0, 200.0, 100.0),
314 );
315
316 let start = MotionProjectionClip::from_projection(projection, 0.0);
317 assert_eq!(start.content_bounds(), rect(30.0, 60.0, 200.0, 100.0));
318 assert_eq!(start.visible_bounds(), rect(10.0, 20.0, 100.0, 50.0));
319 assert_eq!(start.occlusion_bounds(), rect(30.0, 60.0, 200.0, 100.0));
320
321 let end = MotionProjectionClip::from_projection(projection, 1.0);
322 assert_eq!(end.content_bounds(), rect(30.0, 60.0, 200.0, 100.0));
323 assert_eq!(end.visible_bounds(), rect(30.0, 60.0, 200.0, 100.0));
324 }
325
326 #[test]
327 fn reveal_clip_samples_visible_bounds_inside_final_content() {
328 let clip =
329 MotionProjectionClip::reveal(rect(10.0, 20.0, 100.0, 50.0), MotionEdge::Left, 0.25);
330
331 assert_eq!(clip.content_bounds(), rect(10.0, 20.0, 100.0, 50.0));
332 assert_eq!(clip.visible_bounds(), rect(10.0, 20.0, 25.0, 50.0));
333 assert_eq!(clip.occlusion_bounds(), rect(10.0, 20.0, 100.0, 50.0));
334 assert_eq!(clip.progress(), 0.25);
335 }
336
337 #[test]
338 fn near_identity_projection_snaps_to_neutral_values() {
339 let projection = MotionProjection::between(
340 rect(10.002, 20.002, 100.001, 50.001),
341 rect(10.0, 20.0, 100.0, 50.0),
342 );
343
344 assert_eq!(projection.visual_bounds(0.0), rect(10.0, 20.0, 100.0, 50.0));
345 }
346
347 #[test]
348 fn reduced_motion_projection_samples_final_state_without_spatial_movement() {
349 let projection =
350 MotionProjection::between(rect(0.0, 0.0, 100.0, 50.0), rect(50.0, 30.0, 100.0, 50.0));
351
352 assert_eq!(
353 projection.visual_bounds_with_preference(0.25, MotionPreference::Reduced),
354 rect(50.0, 30.0, 100.0, 50.0)
355 );
356 }
357
358 #[test]
359 fn public_visual_bounds_helper_exposes_consumed_projection_capability() {
360 let projection = MotionProjection::between(
361 rect(10.0, 20.0, 100.0, 50.0),
362 rect(30.0, 60.0, 200.0, 100.0),
363 );
364
365 assert_eq!(projection.visual_bounds(0.0), rect(10.0, 20.0, 100.0, 50.0));
366 assert_eq!(
367 projection.visual_bounds(1.0),
368 rect(30.0, 60.0, 200.0, 100.0)
369 );
370 assert_eq!(
371 projection.visual_bounds_with_preference(0.25, MotionPreference::Reduced),
372 rect(30.0, 60.0, 200.0, 100.0)
373 );
374 }
375
376 #[test]
377 fn reveal_sample_uses_final_bounds_as_the_semantic_rect() {
378 let target = rect(30.0, 60.0, 200.0, 100.0);
379 let projection = MotionProjection::between(rect(10.0, 20.0, 100.0, 50.0), target);
380 let clip = MotionProjectionClip::from_projection(projection, 0.25);
381
382 assert_eq!(clip.content_bounds(), target);
383 assert_eq!(clip.occlusion_bounds(), target);
384 }
385}