├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── after_effects.rs └── sine.rs └── src ├── after_effects ├── conv.rs ├── layers │ ├── image.rs │ ├── mod.rs │ ├── null.rs │ ├── precomp.rs │ ├── shape.rs │ ├── solid.rs │ └── text.rs ├── maybe_track.rs ├── mod.rs └── shapes │ ├── color.rs │ ├── ellipse.rs │ ├── fill.rs │ ├── free_poly.rs │ ├── mod.rs │ ├── rect.rs │ ├── star.rs │ ├── stroke.rs │ └── transform.rs ├── combinators ├── chain.rs ├── cutoff.rs ├── cycle.rs ├── interrupt.rs ├── mod.rs └── rev.rs ├── component_wise.rs ├── constant.rs ├── ease.rs ├── function.rs ├── interval.rs ├── interval_track.rs ├── lerp.rs ├── lib.rs ├── spline ├── bezier.rs ├── bezier_ease.rs ├── bezier_path.rs ├── catmull_rom.rs └── mod.rs └── structured ├── affine.rs ├── clock.rs ├── flip.rs ├── mod.rs ├── path.rs ├── radial.rs ├── retarget.rs └── transform.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "**.rs" 7 | - "**.toml" 8 | - ".github/workflows/ci.yml" 9 | push: 10 | branches: [main] 11 | paths: 12 | - "**.rs" 13 | - "**.toml" 14 | - ".github/workflows/ci.yml" 15 | 16 | jobs: 17 | Check_Formatting: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: hecrj/setup-rust-action@v1 22 | with: 23 | rust-version: stable 24 | components: rustfmt 25 | - name: Check Formatting 26 | run: cargo fmt --all -- --check 27 | 28 | Tests: 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | rust_version: [stable, beta, nightly] 33 | platform: 34 | - { target: x86_64-pc-windows-msvc, os: windows-latest } 35 | - { target: x86_64-unknown-linux-gnu, os: ubuntu-latest } 36 | - { target: aarch64-linux-android, os: ubuntu-latest, cmd: "apk --" } 37 | - { target: x86_64-apple-darwin, os: macos-latest } 38 | - { target: aarch64-apple-ios, os: macos-latest } 39 | 40 | env: 41 | RUST_BACKTRACE: 1 42 | CARGO_INCREMENTAL: 0 43 | RUSTFLAGS: "-C debuginfo=0" 44 | CMD: ${{ matrix.platform.cmd }} 45 | 46 | runs-on: ${{ matrix.platform.os }} 47 | steps: 48 | - uses: actions/checkout@v2 49 | 50 | - uses: hecrj/setup-rust-action@v1 51 | with: 52 | rust-version: ${{ matrix.rust_version }}${{ matrix.platform.host }} 53 | targets: ${{ matrix.platform.target }} 54 | 55 | - name: Install cargo-apk 56 | if: contains(matrix.platform.target, 'android') 57 | run: cargo install cargo-apk 58 | 59 | - name: Check documentation 60 | shell: bash 61 | run: cargo doc --no-deps --target ${{ matrix.platform.target }} 62 | 63 | - name: Build 64 | shell: bash 65 | run: cargo $CMD build --verbose --target ${{ matrix.platform.target }} 66 | 67 | - name: Build tests 68 | shell: bash 69 | run: cargo $CMD test --no-run --verbose --target ${{ matrix.platform.target }} 70 | 71 | - name: Run tests 72 | shell: bash 73 | if: ( 74 | !contains(matrix.platform.target, 'android') && 75 | !contains(matrix.platform.target, 'ios')) 76 | run: cargo test --verbose --target ${{ matrix.platform.target }} 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.assist.importGroup": false 3 | } 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "celerity" 3 | version = "0.1.0" 4 | authors = ["Brainium Studios LLC"] 5 | edition = "2021" 6 | description = "Buttery smooth animation toolkit" 7 | documentation = "https://docs.rs/celerity" 8 | repository = "https://github.com/BrainiumLLC/celerity" 9 | readme = "README.md" 10 | license = "Apache-2.0/MIT" 11 | 12 | [dependencies] 13 | bodymovin = { git = "https://github.com/BrainiumLLC/bodymovin-rs" } 14 | d6 = { git = "https://github.com/BrainiumLLC/d6", optional = true } 15 | gee = { git = "https://github.com/BrainiumLLC/gee" } 16 | log = "0.4.11" 17 | paste = "1.0" 18 | rainbow = { git = "https://github.com/BrainiumLLC/rainbow" } 19 | rand = "0.7.3" 20 | replace_with = "0.1.7" 21 | serde = { version = "1.0.123", features = ["derive"] } 22 | serde_json = "1.0.61" 23 | thiserror = "1.0.24" 24 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-2021 Brainium Studios LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # celerity 2 | 3 | Buttery smooth animation toolkit. 4 | 5 | 6 | ## Overview 7 | 8 | Celerity implements primitives for precise animation of arbitrary value types. 9 | 10 | Celerity is largely compatible with the animation model of Adobe After Effects, relying on Cubic Beziers for both temporal and spatial easing. After Effects animations exported using the [`bodymovin`](https://exchange.adobe.com/creativecloud.details.12557.bodymovin.html) plugin can be imported into celerity. This is very similar to the [Lottie](https://airbnb.design/lottie/) web animation framework. 11 | 12 | ## Example 13 | 14 | ``` 15 | TODO 16 | ``` 17 | 18 | ## Traits 19 | 20 | Celerity centers on a few traits: 21 | 22 | - `trait Animatable` - A value type that can be used for animation keyframes. Must be able to `lerp(...)` (linear interpolation) and measure shortest `distance_to()` between two values A and B. `C` is the type of the scalar components (e.g. `f32`). 23 | - `trait Animation` - A time-changing value `V` that you can `sample(...)` at any point in time. 24 | - `trait BoundedAnimation` - An animation with a known duration 25 | 26 | ## Combinators 27 | 28 | Celerity has a set of animation combinators which can be used to produce higher-order animations: 29 | 30 | - `Chain` - Play animation A, then play animation B 31 | - `Cutoff` - Play only part of animation A 32 | - `Cycle` - Repeat animation A indefinitely 33 | - `Interrupt` - Interrupt an animation A in the middle and transition into a smooth animation B 34 | - `Rev` - Reverse a bounded animation 35 | 36 | ## Keyframes vs Intervals 37 | 38 | In the API, there are two ways to specify track animations: 39 | 1) A user-friendly `Keyframe` API 40 | 2) A code-friendly `Interval` API. 41 | 42 | In the first, an animation `Track` contains `Keyframe`s with values at specific points in time. This representation is easiest to define and edit, with a single source of truth for each value. 43 | 44 | In the second, an animation contains `Interval`s, each of which is a self-contained data structure. This representation is optimized for playback. It describes the entire animation between time `t1` and `t2`, with no dependency on the interval before or after. 45 | 46 | -------------------------------------------------------------------------------- /examples/after_effects.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!( 3 | "{:#?}", 4 | celerity::after_effects::Scene::load("../bodymovin/tests/data/shapes.json") 5 | ) 6 | } 7 | -------------------------------------------------------------------------------- /examples/sine.rs: -------------------------------------------------------------------------------- 1 | use celerity::{function::Function, Animation as _}; 2 | use std::time::{Duration, Instant}; 3 | 4 | fn sine(elapsed: Duration) -> f32 { 5 | elapsed.as_secs_f32().recip().sin() 6 | } 7 | 8 | fn main() { 9 | let anim = Function::new(sine); 10 | let start = Instant::now(); 11 | for _ in 0..100 { 12 | println!("{}", anim.sample(Instant::now() - start)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/after_effects/conv.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Scalar}; 2 | use gee::en; 3 | use thiserror::Error; 4 | 5 | pub trait FromValue: Animatable { 6 | type Error: std::error::Error; 7 | 8 | fn from_value(value: f64) -> Result; 9 | } 10 | 11 | pub trait FromMultiDimensional: Animatable { 12 | type Error: std::error::Error; 13 | 14 | fn from_multi_dimensional(value: &[f64]) -> Result; 15 | } 16 | 17 | impl FromValue for S { 18 | type Error = en::CastFailure; 19 | 20 | fn from_value(value: f64) -> Result { 21 | en::try_cast(value) 22 | } 23 | } 24 | 25 | impl FromValue for gee::Angle { 26 | type Error = std::convert::Infallible; 27 | 28 | fn from_value(value: f64) -> Result { 29 | Ok(Self::from_degrees(value)) 30 | } 31 | } 32 | 33 | pub struct WrongLen { 34 | expected: usize, 35 | found: usize, 36 | _marker: std::marker::PhantomData, 37 | } 38 | 39 | impl std::fmt::Debug for WrongLen { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | f.debug_struct("WrongLen") 42 | .field("expected", &self.expected) 43 | .field("found", &self.found) 44 | .finish() 45 | } 46 | } 47 | 48 | impl std::fmt::Display for WrongLen { 49 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 | if self.expected != self.found { 51 | write!( 52 | f, 53 | "type `{}` expected {} components, but multidimensional value contained {} components", 54 | std::any::type_name::(), 55 | self.expected, 56 | self.found, 57 | ) 58 | } else { 59 | unreachable!("`WrongLen` error produced despite `expected` and `found` matching") 60 | } 61 | } 62 | } 63 | 64 | impl std::error::Error for WrongLen {} 65 | 66 | impl WrongLen { 67 | fn check(value: &[f64], expected: usize, f: impl FnOnce(&[f64]) -> U) -> Result { 68 | let found = value.len(); 69 | (found == expected).then(|| f(value)).ok_or_else(|| Self { 70 | expected, 71 | found, 72 | _marker: std::marker::PhantomData, 73 | }) 74 | } 75 | } 76 | 77 | #[derive(Debug, Error)] 78 | pub enum CastFailedOrWrongLen { 79 | #[error(transparent)] 80 | CastFailed(#[from] en::CastFailure), 81 | #[error(transparent)] 82 | WrongLen(#[from] WrongLen), 83 | } 84 | 85 | impl FromMultiDimensional for gee::Point { 86 | type Error = WrongLen; 87 | 88 | fn from_multi_dimensional(value: &[f64]) -> Result { 89 | WrongLen::check(value, 2, |value| Self::new(value[0], value[1])) 90 | } 91 | } 92 | 93 | impl FromMultiDimensional for gee::Size { 94 | type Error = WrongLen; 95 | 96 | fn from_multi_dimensional(value: &[f64]) -> Result { 97 | WrongLen::check(value, 2, |value| Self::new(value[0], value[1])) 98 | } 99 | } 100 | 101 | impl FromMultiDimensional for gee::Vector { 102 | type Error = WrongLen; 103 | 104 | fn from_multi_dimensional(value: &[f64]) -> Result { 105 | // TODO: ignore z in a more deliberate/explicit fashion 106 | WrongLen::check(value, 3, |value| Self::new(value[0], value[1])).or_else( 107 | |Self::Error { .. }| WrongLen::check(value, 2, |value| Self::new(value[0], value[1])), 108 | ) 109 | } 110 | } 111 | 112 | impl FromMultiDimensional for rainbow::LinRgba { 113 | type Error = CastFailedOrWrongLen; 114 | 115 | fn from_multi_dimensional(value: &[f64]) -> Result { 116 | WrongLen::::check(value, 4, |value| { 117 | Ok(rainbow::SrgbRgba::from_f32( 118 | // TODO: should we care about the loss of precision? 119 | // (reminder: this will panic if we actually lose any) 120 | en::try_cast(value[0])?, 121 | en::try_cast(value[1])?, 122 | en::try_cast(value[2])?, 123 | en::try_cast(value[3])?, 124 | ) 125 | .to_linear()) 126 | }) 127 | .map_err(Self::Error::from)? 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/after_effects/layers/image.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use crate::after_effects::shapes; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum ImageError { 7 | #[error("failed to convert `transform`: {0}")] 8 | TransformInvalid(#[from] shapes::TransformError), 9 | } 10 | 11 | #[derive(Debug)] 12 | pub struct Image { 13 | pub texture_id: String, 14 | pub transform: shapes::Transform, 15 | } 16 | 17 | impl Image { 18 | pub fn from_bodymovin( 19 | layer: bodymovin::layers::Image, 20 | frame_rate: f64, 21 | position_scale: &Vec, 22 | _size_scale: &Vec, 23 | ) -> Result { 24 | Ok(Self { 25 | transform: shapes::Transform::from_bodymovin_helper( 26 | layer.transform, 27 | frame_rate, 28 | position_scale, 29 | )?, 30 | texture_id: layer.mixin.ref_id, 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/after_effects/layers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod image; 2 | pub mod null; 3 | pub mod precomp; 4 | pub mod shape; 5 | pub mod solid; 6 | pub mod text; 7 | 8 | pub use self::{image::*, null::*, precomp::*, shape::*, solid::*, text::*}; 9 | use crate::component_wise::ComponentWise; 10 | use bodymovin::layers::AnyLayer; 11 | use gee::Size; 12 | use std::fmt::Debug; 13 | use thiserror::Error; 14 | 15 | #[derive(Debug, Error)] 16 | pub enum LayerError { 17 | #[error("Failed to convert precomp layer: {0}")] 18 | PreCompInvalid(#[from] PreCompError), 19 | #[error("Failed to convert image layer: {0}")] 20 | ImageInvalid(#[from] ImageError), 21 | #[error("Failed to convert solid layer: {0}")] 22 | SolidInvalid(#[from] SolidError), 23 | #[error("Failed to convert null layer: {0}")] 24 | NullInvalid(#[from] NullError), 25 | #[error("Failed to convert shape layer: {0}")] 26 | ShapeInvalid(#[from] ShapeError), 27 | #[error("Failed to convert text layer: {0}")] 28 | TextInvalid(#[from] TextError), 29 | #[error("Unimplemented layer type: {0:?}")] 30 | Unimplemented(bodymovin::layers::AnyLayer), 31 | } 32 | 33 | #[derive(Debug)] 34 | pub enum ResizeOption { 35 | UseWidth, 36 | UseHeight, 37 | UseDepth, 38 | UseSmallest, 39 | UseLargest, 40 | UseAll, 41 | } 42 | 43 | #[derive(Debug)] 44 | pub enum Layer { 45 | PreComp(PreComp), 46 | Solid(Solid), 47 | Image(Image), 48 | Null(Null), 49 | Shape(Shape), 50 | Text(Text), 51 | } 52 | 53 | impl Layer { 54 | pub(crate) fn from_bodymovin( 55 | layer: bodymovin::layers::AnyLayer, 56 | frame_rate: f64, 57 | position_scale: &Vec, 58 | size_scale: &Vec, 59 | ) -> Result { 60 | match layer { 61 | AnyLayer::PreComp(layer) => { 62 | PreComp::from_bodymovin(layer, frame_rate, &position_scale, &size_scale) 63 | .map(Self::PreComp) 64 | .map_err(LayerError::from) 65 | } 66 | AnyLayer::Solid(layer) => { 67 | Solid::from_bodymovin(layer, frame_rate, &position_scale, &size_scale) 68 | .map(Self::Solid) 69 | .map_err(LayerError::from) 70 | } 71 | AnyLayer::Image(layer) => { 72 | Image::from_bodymovin(layer, frame_rate, &position_scale, &size_scale) 73 | .map(Self::Image) 74 | .map_err(LayerError::from) 75 | } 76 | AnyLayer::Null(layer) => { 77 | Null::from_bodymovin(layer, frame_rate, &position_scale, &size_scale) 78 | .map(Self::Null) 79 | .map_err(LayerError::from) 80 | } 81 | AnyLayer::Shape(layer) => { 82 | Shape::from_bodymovin(layer, frame_rate, &position_scale, &size_scale) 83 | .map(Self::Shape) 84 | .map_err(LayerError::from) 85 | } 86 | AnyLayer::Text(layer) => { 87 | Text::from_bodymovin(layer, frame_rate, &position_scale, &size_scale) 88 | .map(Self::Text) 89 | .map_err(LayerError::from) 90 | } 91 | _ => Err(LayerError::Unimplemented(layer)), 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/after_effects/layers/null.rs: -------------------------------------------------------------------------------- 1 | use gee::Size; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum NullError {} 6 | 7 | #[derive(Debug)] 8 | pub struct Null {} 9 | 10 | impl Null { 11 | pub fn from_bodymovin( 12 | _layer: bodymovin::layers::Null, 13 | _frame_rate: f64, 14 | _export_size: &Vec, 15 | _canvas_size: &Vec, 16 | ) -> Result { 17 | Ok(Self {}) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/after_effects/layers/precomp.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use crate::after_effects::shapes; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum PreCompError { 7 | #[error("failed to convert `transform`: {0}")] 8 | TransformInvalid(#[from] shapes::TransformError), 9 | } 10 | 11 | #[derive(Debug)] 12 | pub struct PreComp { 13 | pub id: String, 14 | // TODO: Layers from PreComp assets 15 | // layers: Vec, 16 | pub transform: shapes::Transform, 17 | } 18 | 19 | impl PreComp { 20 | pub fn from_bodymovin( 21 | layer: bodymovin::layers::PreComp, 22 | frame_rate: f64, 23 | position_scale: &Vec, 24 | _canvas_size: &Vec, 25 | ) -> Result { 26 | Ok(Self { 27 | id: layer.mixin.ref_id, 28 | // layers: layer.layers, 29 | transform: shapes::Transform::from_bodymovin_helper( 30 | layer.transform, 31 | frame_rate, 32 | position_scale, 33 | )?, 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/after_effects/layers/shape.rs: -------------------------------------------------------------------------------- 1 | use crate::after_effects::shapes; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum ShapeError { 6 | #[error("failed to convert `transform`: {0}")] 7 | TransformInvalid(#[from] shapes::TransformError), 8 | #[error("failed to convert `shapes`: {0}")] 9 | ShapeInvalid(#[from] shapes::Error), 10 | } 11 | 12 | #[derive(Debug)] 13 | pub struct Shape { 14 | pub transform: shapes::Transform, 15 | pub shapes: Vec, 16 | } 17 | 18 | impl Shape { 19 | pub fn from_bodymovin( 20 | layer: bodymovin::layers::Shape, 21 | frame_rate: f64, 22 | position_scale: &Vec, 23 | size_scale: &Vec, 24 | ) -> Result { 25 | Ok(Self { 26 | transform: shapes::Transform::from_bodymovin_helper( 27 | layer.transform, 28 | frame_rate, 29 | position_scale, 30 | )?, 31 | shapes: layer 32 | .mixin 33 | .shapes 34 | .into_iter() 35 | // TODO: what to do with the options here? 36 | .flat_map(|shape| { 37 | shapes::Shape::from_bodymovin(shape, frame_rate, position_scale, size_scale) 38 | .transpose() 39 | }) 40 | .collect::>()?, 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/after_effects/layers/solid.rs: -------------------------------------------------------------------------------- 1 | use gee::Size; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum SolidError {} 6 | 7 | #[derive(Debug)] 8 | pub struct Solid { 9 | color: String, // TODO: Convert to SrgbRgba 10 | size: Size, 11 | } 12 | 13 | impl Solid { 14 | pub fn from_bodymovin( 15 | layer: bodymovin::layers::Solid, 16 | _frame_rate: f64, 17 | _export_size: &Vec, 18 | _canvas_size: &Vec, 19 | ) -> Result { 20 | Ok(Self { 21 | color: layer.mixin.color, 22 | size: Size::new(layer.mixin.width, layer.mixin.height), 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/after_effects/layers/text.rs: -------------------------------------------------------------------------------- 1 | use crate::after_effects::shapes; 2 | use gee::Size; 3 | use thiserror::Error; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum TextError { 7 | #[error("failed to convert `transform`: {0}")] 8 | TransformInvalid(#[from] shapes::TransformError), 9 | } 10 | 11 | #[derive(Debug)] 12 | pub struct Text { 13 | pub transform: shapes::Transform, 14 | pub text: String, 15 | pub line_height: f64, 16 | } 17 | 18 | impl Text { 19 | pub fn from_bodymovin( 20 | layer: bodymovin::layers::Text, 21 | frame_rate: f64, 22 | position_scale: &Vec, 23 | size_scale: &Vec, 24 | ) -> Result { 25 | Ok(Self { 26 | transform: shapes::Transform::from_bodymovin_helper( 27 | layer.transform, 28 | frame_rate, 29 | position_scale, 30 | )?, 31 | text: layer.mixin.text_data.document_data.keyframe_data[0] 32 | .properties 33 | .text 34 | .clone(), 35 | line_height: layer.mixin.text_data.document_data.keyframe_data[0] 36 | .properties 37 | .line_height 38 | * size_scale[1], 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/after_effects/maybe_track.rs: -------------------------------------------------------------------------------- 1 | use super::conv::{FromMultiDimensional, FromValue}; 2 | use crate::{ 3 | ease::Ease, interval::Interval, interval_track::IntervalTrack, spline::bezier_ease::BezierEase, 4 | Animatable, Animation, 5 | }; 6 | use bodymovin::properties::{self, ScalarKeyframe, Value}; 7 | use std::time::Duration; 8 | 9 | impl From for BezierEase { 10 | fn from(bezier: bodymovin::properties::Bezier2d) -> Self { 11 | Self { 12 | ox: bezier.out_value.x, 13 | oy: bezier.out_value.y, 14 | ix: bezier.in_value.x, 15 | iy: bezier.in_value.y, 16 | } 17 | } 18 | } 19 | 20 | impl From for BezierEase { 21 | fn from(bezier: bodymovin::properties::Bezier3d) -> Self { 22 | Self { 23 | ox: bezier.out_value.x[0], 24 | oy: bezier.out_value.y[0], 25 | ix: bezier.in_value.x[0], 26 | iy: bezier.in_value.y[0], 27 | } 28 | } 29 | } 30 | 31 | #[derive(Debug)] 32 | pub enum MaybeTrack { 33 | Fixed(V), 34 | Animated(IntervalTrack), 35 | } 36 | 37 | impl Animation for MaybeTrack { 38 | fn sample(&self, elapsed: Duration) -> V { 39 | match self { 40 | Self::Fixed(value) => *value, 41 | Self::Animated(track) => track.sample(elapsed), 42 | } 43 | } 44 | } 45 | 46 | #[derive(Debug)] 47 | struct KeyframePair { 48 | start: Duration, 49 | end: Duration, 50 | from: V, 51 | to: V, 52 | ease: Option, 53 | } 54 | 55 | impl Interval { 56 | fn from_scalar_keyframes( 57 | frame_a: &ScalarKeyframe, 58 | frame_b: &ScalarKeyframe, 59 | frame_rate: f64, 60 | ) -> Result, V::Error> { 61 | let from_value = V::from_value( 62 | frame_a 63 | .start_value 64 | .expect("ScalarKeyframe is missing start_value.") 65 | .0, 66 | )?; 67 | let to_value = if !frame_a.hold { 68 | V::from_value( 69 | frame_a 70 | .end_value 71 | .unwrap_or_else(|| { 72 | frame_b 73 | .start_value 74 | .expect("ScalarKeyframe is missing start_value.") 75 | }) 76 | .0, 77 | )? 78 | } else { 79 | from_value 80 | }; 81 | Ok(Interval { 82 | start: Duration::from_secs_f64(frame_a.start_time / frame_rate), 83 | end: Duration::from_secs_f64(frame_b.start_time / frame_rate), 84 | from: from_value, 85 | to: to_value, 86 | ease: frame_a.bezier.clone().map(|ease| match ease { 87 | bodymovin::properties::BezierEase::_2D(ease) => Ease::Bezier(Into::into(ease)), 88 | bodymovin::properties::BezierEase::_3D(ease) => Ease::Bezier(Into::into(ease)), 89 | }), 90 | path: None, 91 | reticulated_spline: None, 92 | }) 93 | } 94 | } 95 | 96 | impl Interval { 97 | pub(crate) fn from_multidimensional_keyframes( 98 | from: &bodymovin::properties::MultiDimensionalKeyframe, 99 | to: &bodymovin::properties::MultiDimensionalKeyframe, 100 | frame_rate: f64, 101 | ) -> Result, V::Error> { 102 | let from_value = V::from_multi_dimensional( 103 | from.start_value 104 | .as_ref() 105 | .expect("Attempted to create Interval with no starting value."), 106 | )?; 107 | let to_value = if !from.hold { 108 | V::from_multi_dimensional(to.start_value.as_ref().unwrap_or(&vec![0.0, 0.0]))? 109 | } else { 110 | from_value 111 | }; 112 | Ok(Interval { 113 | start: Duration::from_secs_f64(from.start_time.abs() / frame_rate), 114 | end: Duration::from_secs_f64(to.start_time.abs() / frame_rate), 115 | from: from_value, 116 | to: to_value, 117 | ease: from.bezier.clone().map(|ease| match ease { 118 | bodymovin::properties::BezierEase::_2D(ease) => Ease::Bezier(Into::into(ease)), 119 | bodymovin::properties::BezierEase::_3D(ease) => Ease::Bezier(Into::into(ease)), 120 | }), 121 | path: None, 122 | reticulated_spline: None, 123 | }) 124 | } 125 | } 126 | 127 | impl MaybeTrack { 128 | pub(crate) fn from_value( 129 | value: Value, 130 | frame_rate: f64, 131 | ) -> Result { 132 | match value { 133 | Value::Fixed(value) => V::from_value(value).map(Self::Fixed), 134 | Value::Animated(keyframes) => keyframes 135 | .windows(2) 136 | .map(|window| Interval::from_scalar_keyframes(&window[0], &window[1], frame_rate)) 137 | .collect::, _>>() 138 | .map(IntervalTrack::from_intervals) 139 | .map(Self::Animated), 140 | } 141 | } 142 | 143 | pub(crate) fn from_property( 144 | property: bodymovin::properties::Property, 145 | frame_rate: f64, 146 | ) -> Result { 147 | Self::from_value(property.value, frame_rate) 148 | } 149 | } 150 | 151 | impl MaybeTrack { 152 | pub(crate) fn from_multi_dimensional( 153 | multi: properties::MultiDimensional, 154 | frame_rate: f64, 155 | ) -> Result { 156 | match multi.value { 157 | Value::Fixed(value) => V::from_multi_dimensional(&value).map(Self::Fixed), 158 | Value::Animated(keyframes) => keyframes 159 | .windows(2) 160 | .map(|window| { 161 | Interval::from_multidimensional_keyframes(&window[0], &window[1], frame_rate) 162 | }) 163 | .collect::, _>>() 164 | .map(IntervalTrack::from_intervals) 165 | .map(Self::Animated), 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/after_effects/mod.rs: -------------------------------------------------------------------------------- 1 | mod conv; 2 | pub mod layers; 3 | mod maybe_track; 4 | pub mod shapes; 5 | 6 | use self::layers::{Layer, ResizeOption}; 7 | pub use self::maybe_track::MaybeTrack; 8 | use crate::component_wise::ComponentWise; 9 | pub use bodymovin; 10 | use gee::Size; 11 | use std::{fmt::Debug, path::Path}; 12 | use thiserror::Error; 13 | 14 | #[derive(Debug, Error)] 15 | pub enum Error { 16 | #[error("Failed to load exported animation: {0}")] 17 | ParseFailed(#[from] bodymovin::Error), 18 | #[error(transparent)] 19 | LayerInvalid(#[from] layers::LayerError), 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct Scene { 24 | pub size: gee::Size, 25 | pub layers: Vec, 26 | } 27 | 28 | impl Scene { 29 | pub fn load( 30 | path: impl AsRef, 31 | canvas_size: Size, 32 | size_scale: ResizeOption, 33 | ) -> Result { 34 | bodymovin::Bodymovin::load(path) 35 | .map_err(Error::from) 36 | .and_then(|bodymovin| Self::from_bodymovin(bodymovin, canvas_size, size_scale)) 37 | } 38 | 39 | pub fn from_bytes( 40 | bytes: impl AsRef<[u8]>, 41 | canvas_size: Size, 42 | size_scale: ResizeOption, 43 | ) -> Result { 44 | bodymovin::Bodymovin::from_bytes(bytes) 45 | .map_err(Error::from) 46 | .and_then(|bodymovin| Self::from_bodymovin(bodymovin, canvas_size, size_scale)) 47 | } 48 | 49 | pub fn from_bodymovin( 50 | bodymovin::Bodymovin { 51 | width, 52 | height, 53 | frame_rate, 54 | layers, 55 | .. 56 | }: bodymovin::Bodymovin, 57 | canvas_size: Size, 58 | size_scale: ResizeOption, 59 | ) -> Result { 60 | let (position_scale, size_scale) = 61 | Self::calculate_scales2d(Size::new(width, height).to_f64(), canvas_size, size_scale); 62 | 63 | layers 64 | .into_iter() 65 | .map(|layer| Layer::from_bodymovin(layer, frame_rate, &position_scale, &size_scale)) 66 | .collect::>() 67 | .map(|layers| Self { 68 | size: canvas_size.to_i64(), 69 | layers, 70 | }) 71 | .map_err(Error::from) 72 | } 73 | 74 | pub fn calculate_scales2d( 75 | export_size: Size, 76 | canvas_size: Size, 77 | size_scale: ResizeOption, 78 | ) -> (Vec, Vec) { 79 | let rel_size = canvas_size.zip_map(export_size, |c, e| c / e); 80 | let position_scale = vec![rel_size.width, rel_size.height]; 81 | let size_scale = match size_scale { 82 | ResizeOption::UseWidth => vec![position_scale[0]; position_scale.len()], 83 | ResizeOption::UseHeight => vec![position_scale[1]; position_scale.len()], 84 | ResizeOption::UseDepth => vec![position_scale[2]; position_scale.len()], 85 | ResizeOption::UseLargest => { 86 | vec![position_scale.iter().fold(0.0, |a, b| f64::max(a, *b)); position_scale.len()] 87 | } 88 | ResizeOption::UseSmallest => { 89 | vec![position_scale.iter().fold(0.0, |a, b| f64::min(a, *b)); position_scale.len()] 90 | } 91 | ResizeOption::UseAll => position_scale.clone(), 92 | }; 93 | 94 | (position_scale, size_scale) 95 | } 96 | 97 | pub fn print(&self) { 98 | println!("Scene size: {:?}", self.size); 99 | 100 | // TODO: PreComp & Image assets 101 | // for asset in &scene.assets { 102 | // println!("Asset found! {:?}", asset); 103 | // } 104 | 105 | for layer in &self.layers { 106 | match layer { 107 | Layer::Shape(shape) => { 108 | println!("\tShape Layer: {:?}", shape.transform); 109 | println!("\t\tContaining Shapes:\n{:?}", shape.shapes); 110 | } 111 | Layer::PreComp(precomp) => { 112 | println!("\tPreComp Layer Found: {}", precomp.id); 113 | println!("\t\tWith Transform:\n{:?}", precomp.transform); 114 | } 115 | Layer::Image(image) => { 116 | println!("\tImage Layer: {:?}", image.texture_id); 117 | println!("\t\tWith Transform: {:?}", image.transform); 118 | } 119 | Layer::Text(text) => { 120 | println!( 121 | "\tText Layer: \"{:?}\": height: {}\n\t\t{:?}", 122 | text.text, text.line_height, text.transform 123 | ); 124 | } 125 | Layer::Null(null) => { 126 | println!("Null Layer!"); 127 | } 128 | Layer::Solid(solid) => { 129 | println!("Solid Layer!"); 130 | } 131 | _ => { 132 | println!("\tUnrecognized Layer Type."); 133 | } 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/after_effects/shapes/color.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | after_effects::{ 3 | conv::{FromMultiDimensional, FromValue}, 4 | MaybeTrack, 5 | }, 6 | Animation as _, 7 | }; 8 | use bodymovin::properties::ScalarKeyframe; 9 | use std::time::Duration; 10 | use thiserror::Error; 11 | 12 | #[derive(Debug, Error)] 13 | pub enum SolidError { 14 | #[error("Failed to convert `color`: {0}")] 15 | ColorInvalid(#[from] ::Error), 16 | } 17 | 18 | #[derive(Debug, Error)] 19 | pub enum GradientTypeError { 20 | #[error("Failed to convert `highlight_length`: {0}")] 21 | HighlightLengthInvalid(#[from] ::Error), 22 | #[error("Failed to convert `highlight_angle`: {0}")] 23 | HighlightAngleInvalid(#[from] as FromValue>::Error), 24 | #[error( 25 | "Missing `highlight_length` or `highlight_angle` despite being a radial-type gradient" 26 | )] 27 | HighlightFieldsMissing, 28 | } 29 | 30 | #[derive(Debug, Error)] 31 | pub enum GradientError { 32 | #[error("Failed to convert `start_point`: {0}")] 33 | StartPointInvalid(#[source] as FromMultiDimensional>::Error), 34 | #[error("Failed to convert `end_point`: {0}")] 35 | EndPointInvalid(#[source] as FromMultiDimensional>::Error), 36 | #[error("Failed to classify gradient: {0}")] 37 | TyInvalid(#[from] GradientTypeError), 38 | } 39 | 40 | #[derive(Debug)] 41 | pub enum GradientType { 42 | Linear, 43 | // TODO: upstream this nicenes 44 | Radial { 45 | highlight_length: MaybeTrack, 46 | highlight_angle: MaybeTrack>, 47 | }, 48 | } 49 | 50 | impl GradientType { 51 | fn from_bodymovin( 52 | ty: bodymovin::shapes::GradientType, 53 | highlight_length: Option>, 54 | highlight_angle: Option>, 55 | frame_rate: f64, 56 | ) -> Result { 57 | match ty { 58 | bodymovin::shapes::GradientType::Linear => { 59 | if highlight_length.is_some() || highlight_angle.is_some() { 60 | log::warn!( 61 | "gradient specifies a highlight length or angle despite being linear" 62 | ); 63 | } 64 | Ok(Self::Linear) 65 | } 66 | bodymovin::shapes::GradientType::Radial => highlight_length 67 | .zip(highlight_angle) 68 | .ok_or_else(|| GradientTypeError::HighlightFieldsMissing) 69 | .and_then(|(highlight_length, highlight_angle)| { 70 | Ok(Self::Radial { 71 | highlight_length: MaybeTrack::from_value(highlight_length, frame_rate)?, 72 | highlight_angle: MaybeTrack::from_value(highlight_angle, frame_rate)?, 73 | }) 74 | }), 75 | } 76 | } 77 | } 78 | 79 | #[derive(Debug)] 80 | pub struct Gradient { 81 | pub start_point: MaybeTrack>, 82 | pub end_point: MaybeTrack>, 83 | pub ty: GradientType, 84 | } 85 | 86 | #[derive(Debug)] 87 | pub enum Color { 88 | Solid(MaybeTrack), 89 | Gradient(Gradient), 90 | } 91 | 92 | impl Color { 93 | pub(crate) fn from_bodymovin_solid( 94 | color: bodymovin::properties::MultiDimensional, 95 | frame_rate: f64, 96 | ) -> Result { 97 | MaybeTrack::from_multi_dimensional(color, frame_rate) 98 | .map(Self::Solid) 99 | .map_err(SolidError::from) 100 | } 101 | 102 | pub(crate) fn from_bodymovin_gradient( 103 | start_point: bodymovin::properties::MultiDimensional, 104 | end_point: bodymovin::properties::MultiDimensional, 105 | ty: bodymovin::shapes::GradientType, 106 | highlight_length: Option>, 107 | highlight_angle: Option>, 108 | // color: ???, 109 | frame_rate: f64, 110 | ) -> Result { 111 | log::warn!("gradient colors aren't implemented yet"); 112 | Ok(Self::Gradient(Gradient { 113 | start_point: MaybeTrack::from_multi_dimensional(start_point, frame_rate) 114 | .map_err(GradientError::StartPointInvalid)?, 115 | end_point: MaybeTrack::from_multi_dimensional(end_point, frame_rate) 116 | .map_err(GradientError::EndPointInvalid)?, 117 | ty: GradientType::from_bodymovin(ty, highlight_length, highlight_angle, frame_rate)?, 118 | })) 119 | } 120 | 121 | pub fn sample_color(&self, elapsed: Duration) -> Option { 122 | if let Self::Solid(color) = self { 123 | let color = color.sample(elapsed); 124 | log::info!("sampled color `{:?}`", color); 125 | Some(color) 126 | } else { 127 | None 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/after_effects/shapes/ellipse.rs: -------------------------------------------------------------------------------- 1 | use crate::after_effects::{conv::FromMultiDimensional, MaybeTrack}; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum EllipseError { 6 | #[error("Failed to convert `position`: {0}")] 7 | PositionInvalid(#[from] as FromMultiDimensional>::Error), 8 | #[error("Failed to convert `size`: {0}")] 9 | SizeInvalid(#[from] as FromMultiDimensional>::Error), 10 | } 11 | 12 | #[derive(Debug)] 13 | pub struct Ellipse { 14 | pub direction: f64, 15 | pub position: MaybeTrack>, 16 | pub size: MaybeTrack>, 17 | } 18 | 19 | impl Ellipse { 20 | pub(crate) fn from_bodymovin( 21 | ellipse: bodymovin::shapes::Ellipse, 22 | frame_rate: f64, 23 | position_scale: &Vec, 24 | size_scale: &Vec, 25 | ) -> Result { 26 | Ok(Self { 27 | direction: ellipse.direction, 28 | position: MaybeTrack::from_multi_dimensional( 29 | ellipse.position.scaled(position_scale), 30 | frame_rate, 31 | )?, 32 | size: MaybeTrack::from_multi_dimensional(ellipse.size.scaled(size_scale), frame_rate)?, 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/after_effects/shapes/fill.rs: -------------------------------------------------------------------------------- 1 | use crate::after_effects::{ 2 | conv::FromValue, 3 | shapes::{Color, GradientError, SolidError}, 4 | MaybeTrack, 5 | }; 6 | use thiserror::Error; 7 | 8 | #[derive(Debug, Error)] 9 | pub enum FillError { 10 | #[error("Failed to convert `opacity`: {0}")] 11 | OpacityInvalid(#[from] ::Error), 12 | #[error("Failed to convert `color`: {0}")] 13 | ColorInvalid(#[from] SolidError), 14 | #[error("Failed to convert gradient: {0}")] 15 | GradientInvalid(#[from] GradientError), 16 | } 17 | 18 | #[derive(Debug)] 19 | pub struct Fill { 20 | pub opacity: MaybeTrack, 21 | pub color: Color, 22 | } 23 | 24 | impl Fill { 25 | pub(crate) fn from_bodymovin( 26 | fill: bodymovin::shapes::Fill, 27 | frame_rate: f64, 28 | ) -> Result { 29 | Ok(Self { 30 | opacity: MaybeTrack::from_property(fill.opacity, frame_rate)?, 31 | color: Color::from_bodymovin_solid(fill.color, frame_rate)?, 32 | }) 33 | } 34 | 35 | pub(crate) fn from_bodymovin_with_gradient( 36 | fill: bodymovin::shapes::GradientFill, 37 | frame_rate: f64, 38 | ) -> Result { 39 | Ok(Self { 40 | opacity: MaybeTrack::from_property(fill.opacity, frame_rate)?, 41 | color: Color::from_bodymovin_gradient( 42 | fill.start_point, 43 | fill.end_point, 44 | fill.ty, 45 | Some(fill.highlight_length.unwrap_or_default().value), 46 | Some(fill.highlight_angle.unwrap_or_default().value), 47 | frame_rate, 48 | )?, 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/after_effects/shapes/free_poly.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | pub enum FreePolyError {} 5 | 6 | #[derive(Debug)] 7 | pub struct FreePoly { 8 | pub direction: f64, 9 | // pub vertices: MaybeTrack, 10 | } 11 | 12 | impl FreePoly { 13 | pub(crate) fn from_bodymovin( 14 | shape: bodymovin::shapes::Shape, 15 | _frame_rate: f64, 16 | _position_scale: &Vec, 17 | _size_scale: &Vec, 18 | ) -> Result { 19 | log::warn!("free polygons aren't implemented yet"); 20 | Ok(Self { 21 | direction: shape.direction.unwrap_or(0.0), 22 | // vertices: shape.vertices.into(), 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/after_effects/shapes/mod.rs: -------------------------------------------------------------------------------- 1 | mod color; 2 | mod ellipse; 3 | mod fill; 4 | mod free_poly; 5 | mod rect; 6 | mod star; 7 | mod stroke; 8 | mod transform; 9 | 10 | pub use self::{ 11 | color::*, ellipse::*, fill::*, free_poly::*, rect::*, star::*, stroke::*, transform::*, 12 | }; 13 | 14 | use std::fmt::Debug; 15 | use thiserror::Error; 16 | 17 | #[derive(Debug, Error)] 18 | pub enum Error { 19 | #[error("Failed to convert free poly: {0}")] 20 | FreePolyInvalid(#[from] FreePolyError), 21 | #[error("Failed to convert rect: {0}")] 22 | RectInvalid(#[from] RectError), 23 | #[error("Failed to convert ellipse: {0}")] 24 | EllipseInvalid(#[from] EllipseError), 25 | #[error("Failed to convert star: {0}")] 26 | StarInvalid(#[from] StarError), 27 | #[error("Failed to convert fill: {0}")] 28 | FillInvalid(#[from] FillError), 29 | #[error("Failed to convert stroke: {0}")] 30 | StrokeInvalid(#[from] StrokeError), 31 | #[error("Failed to convert transform: {0}")] 32 | TransformInvalid(#[from] TransformError), 33 | #[error("Nested groups aren't implemented yet")] 34 | NestedGroup, 35 | #[error("Top-level shapes that aren't groups aren't implemented yet")] 36 | NotAGroup, 37 | } 38 | 39 | #[derive(Debug)] 40 | pub enum Geometry { 41 | FreePoly(FreePoly), 42 | Rect(Rect), 43 | Ellipse(Ellipse), 44 | Star(Star), 45 | Null, 46 | } 47 | 48 | #[derive(Debug, Default)] 49 | pub struct Style { 50 | pub fill: Option, 51 | pub stroke: Option, 52 | // pub merge: Option, 53 | // pub trim: Option, 54 | // pub rounded_corners: Option, 55 | pub transform: Option, 56 | } 57 | 58 | #[derive(Debug)] 59 | pub struct Shape { 60 | pub geometry: Geometry, 61 | pub style: Style, 62 | } 63 | 64 | fn politely_set(dest: &mut Option, val: T) { 65 | if let Some(existing) = dest { 66 | log::warn!( 67 | "ignoring `{:?}` because we already have `{:?}`; this is probably a bug", 68 | val, 69 | existing 70 | ); 71 | } else { 72 | *dest = Some(val); 73 | } 74 | } 75 | 76 | impl Shape { 77 | fn from_group( 78 | group: bodymovin::shapes::Group, 79 | frame_rate: f64, 80 | position_scale: &Vec, 81 | size_scale: &Vec, 82 | ) -> Result, Error> { 83 | let mut geometry = None; 84 | let mut style = Style::default(); 85 | for item in group.items { 86 | match item { 87 | // Geometry 88 | bodymovin::shapes::AnyShape::Shape(shape) => { 89 | geometry = Some(Geometry::FreePoly(FreePoly::from_bodymovin( 90 | shape, 91 | frame_rate, 92 | position_scale, 93 | size_scale, 94 | )?)); 95 | } 96 | bodymovin::shapes::AnyShape::Rect(rect) => { 97 | geometry = Some(Geometry::Rect(Rect::from_bodymovin( 98 | rect, 99 | frame_rate, 100 | position_scale, 101 | size_scale, 102 | )?)); 103 | } 104 | bodymovin::shapes::AnyShape::Ellipse(ellipse) => { 105 | geometry = Some(Geometry::Ellipse(Ellipse::from_bodymovin( 106 | ellipse, 107 | frame_rate, 108 | position_scale, 109 | size_scale, 110 | )?)); 111 | } 112 | bodymovin::shapes::AnyShape::Star(star) => { 113 | geometry = Some(Geometry::Star(Star::from_bodymovin( 114 | star, 115 | frame_rate, 116 | position_scale, 117 | size_scale[0], // TODO: How to select which axis to use for radius scaling? 118 | )?)); 119 | } 120 | 121 | // Style 122 | bodymovin::shapes::AnyShape::Fill(fill) => { 123 | politely_set(&mut style.fill, Fill::from_bodymovin(fill, frame_rate)?); 124 | } 125 | bodymovin::shapes::AnyShape::GradientFill(gradient_fill) => { 126 | politely_set( 127 | &mut style.fill, 128 | Fill::from_bodymovin_with_gradient(gradient_fill, frame_rate)?, 129 | ); 130 | } 131 | bodymovin::shapes::AnyShape::Stroke(stroke) => { 132 | politely_set( 133 | &mut style.stroke, 134 | Stroke::from_bodymovin(stroke, frame_rate, size_scale[0])?, // TODO: Which axis to use for stroke scaling? 135 | ); 136 | } 137 | bodymovin::shapes::AnyShape::GradientStroke(gradient_stroke) => { 138 | politely_set( 139 | &mut style.stroke, 140 | Stroke::from_bodymovin_with_gradient(gradient_stroke, frame_rate)?, 141 | ); 142 | } 143 | bodymovin::shapes::AnyShape::Merge(_merge) => { 144 | log::warn!("merges aren't implemented yet; ignoring"); 145 | // politely_set(&mut style.merge, merge); 146 | } 147 | bodymovin::shapes::AnyShape::Trim(_trim) => { 148 | log::warn!("trims aren't implemented yet; ignoring"); 149 | // politely_set(&mut style.trim, trim); 150 | } 151 | bodymovin::shapes::AnyShape::RoundedCorners(_rounded_corners) => { 152 | log::warn!("rounded corners aren't implemented yet; ignoring"); 153 | // politely_set(&mut style.rounded_corners, rounded_corners); 154 | } 155 | bodymovin::shapes::AnyShape::Transform(transform) => { 156 | politely_set( 157 | &mut style.transform, 158 | Transform::from_bodymovin_shape(transform, frame_rate, position_scale)?, 159 | ); 160 | } 161 | 162 | bodymovin::shapes::AnyShape::Group(_group) => { 163 | // TODO: do we need to support this? 164 | Err(Error::NestedGroup)? 165 | } 166 | 167 | bodymovin::shapes::AnyShape::Repeater(_) => Err(Error::NotAGroup)?, 168 | } 169 | } 170 | Ok(geometry.map(|geometry| Self { geometry, style })) 171 | } 172 | 173 | pub fn from_anyshape( 174 | shape: bodymovin::shapes::AnyShape, 175 | frame_rate: f64, 176 | position_scale: &Vec, 177 | size_scale: &Vec, 178 | ) -> Result { 179 | match shape { 180 | // Geometry 181 | bodymovin::shapes::AnyShape::Shape(shape) => Ok(Self { 182 | geometry: Geometry::FreePoly(FreePoly::from_bodymovin( 183 | shape, 184 | frame_rate, 185 | position_scale, 186 | size_scale, 187 | )?), 188 | style: Style::default(), 189 | }), 190 | bodymovin::shapes::AnyShape::Rect(rect) => Ok(Self { 191 | geometry: Geometry::Rect(Rect::from_bodymovin( 192 | rect, 193 | frame_rate, 194 | position_scale, 195 | size_scale, 196 | )?), 197 | style: Style::default(), 198 | }), 199 | bodymovin::shapes::AnyShape::Ellipse(ellipse) => Ok(Self { 200 | geometry: Geometry::Ellipse(Ellipse::from_bodymovin( 201 | ellipse, 202 | frame_rate, 203 | position_scale, 204 | size_scale, 205 | )?), 206 | style: Style::default(), 207 | }), 208 | bodymovin::shapes::AnyShape::Star(star) => Ok(Self { 209 | geometry: Geometry::Star(Star::from_bodymovin( 210 | star, 211 | frame_rate, 212 | position_scale, 213 | size_scale[0], // TODO: How to select which axis to use for radius scaling? 214 | )?), 215 | style: Style::default(), 216 | }), 217 | 218 | // Style 219 | bodymovin::shapes::AnyShape::Fill(fill) => Ok(Self { 220 | geometry: Geometry::Null, 221 | style: Style { 222 | fill: Some(Fill::from_bodymovin(fill, frame_rate)?), 223 | ..Style::default() 224 | }, 225 | }), 226 | bodymovin::shapes::AnyShape::GradientFill(gradient_fill) => Ok(Self { 227 | geometry: Geometry::Null, 228 | style: Style { 229 | fill: Some(Fill::from_bodymovin_with_gradient( 230 | gradient_fill, 231 | frame_rate, 232 | )?), 233 | ..Style::default() 234 | }, 235 | }), 236 | bodymovin::shapes::AnyShape::Stroke(stroke) => Ok(Self { 237 | geometry: Geometry::Null, 238 | style: Style { 239 | stroke: Some(Stroke::from_bodymovin(stroke, frame_rate, size_scale[0])?), 240 | ..Style::default() 241 | }, 242 | }), 243 | bodymovin::shapes::AnyShape::GradientStroke(gradient_stroke) => Ok(Self { 244 | geometry: Geometry::Null, 245 | style: Style { 246 | stroke: Some(Stroke::from_bodymovin_with_gradient( 247 | gradient_stroke, 248 | frame_rate, 249 | )?), 250 | ..Style::default() 251 | }, 252 | }), 253 | bodymovin::shapes::AnyShape::Merge(_merge) => { 254 | log::warn!("merges aren't implemented yet; ignoring"); 255 | Ok(Self { 256 | geometry: Geometry::Null, 257 | style: Style::default(), 258 | }) 259 | } 260 | bodymovin::shapes::AnyShape::Trim(_trim) => { 261 | log::warn!("trims aren't implemented yet; ignoring"); 262 | Ok(Self { 263 | geometry: Geometry::Null, 264 | style: Style::default(), 265 | }) 266 | } 267 | bodymovin::shapes::AnyShape::RoundedCorners(_rounded_corners) => { 268 | log::warn!("rounded corners aren't implemented yet; ignoring"); 269 | Ok(Self { 270 | geometry: Geometry::Null, 271 | style: Style::default(), 272 | }) 273 | } 274 | bodymovin::shapes::AnyShape::Transform(transform) => Ok(Self { 275 | geometry: Geometry::Null, 276 | style: Style { 277 | transform: Some(Transform::from_bodymovin_shape( 278 | transform, 279 | frame_rate, 280 | position_scale, 281 | )?), 282 | ..Style::default() 283 | }, 284 | }), 285 | 286 | bodymovin::shapes::AnyShape::Group(group) => { 287 | Ok(Self::from_group(group, frame_rate, position_scale, size_scale)?.unwrap()) 288 | } 289 | 290 | bodymovin::shapes::AnyShape::Repeater(_) => Err(Error::NotAGroup)?, 291 | } 292 | } 293 | 294 | pub(crate) fn from_bodymovin( 295 | shape: bodymovin::shapes::AnyShape, 296 | frame_rate: f64, 297 | position_scale: &Vec, 298 | size_scale: &Vec, 299 | ) -> Result, Error> { 300 | match shape { 301 | bodymovin::shapes::AnyShape::Group(group) => { 302 | Self::from_group(group, frame_rate, position_scale, size_scale) 303 | } 304 | 305 | bodymovin::shapes::AnyShape::Shape(shape) => { 306 | println!("Shape!"); 307 | Ok(Some(Self::from_anyshape( 308 | bodymovin::shapes::AnyShape::Shape(shape), 309 | frame_rate, 310 | position_scale, 311 | size_scale, 312 | )?)) 313 | } 314 | bodymovin::shapes::AnyShape::Rect(rect) => { 315 | println!("Rect!"); 316 | Ok(Some(Self::from_anyshape( 317 | bodymovin::shapes::AnyShape::Rect(rect), 318 | frame_rate, 319 | position_scale, 320 | size_scale, 321 | )?)) 322 | } 323 | bodymovin::shapes::AnyShape::Ellipse(ellipse) => { 324 | println!("Ellipse!"); 325 | Err(Error::NotAGroup) 326 | } 327 | bodymovin::shapes::AnyShape::Star(star) => { 328 | println!("Star!"); 329 | Err(Error::NotAGroup) 330 | } 331 | bodymovin::shapes::AnyShape::Fill(fill) => { 332 | println!("Fill!"); 333 | Err(Error::NotAGroup) 334 | } 335 | bodymovin::shapes::AnyShape::GradientFill(gradient_fill) => { 336 | println!("GradientFill!"); 337 | Err(Error::NotAGroup) 338 | } 339 | bodymovin::shapes::AnyShape::GradientStroke(gradient_stroke) => { 340 | println!("GradientStroke!"); 341 | Err(Error::NotAGroup) 342 | } 343 | bodymovin::shapes::AnyShape::Stroke(stroke) => { 344 | println!("Stroke!"); 345 | Err(Error::NotAGroup) 346 | } 347 | bodymovin::shapes::AnyShape::Merge(merge) => { 348 | println!("Merge!"); 349 | Err(Error::NotAGroup) 350 | } 351 | bodymovin::shapes::AnyShape::Trim(trim) => { 352 | println!("Trim!"); 353 | Ok(Some(Self::from_anyshape( 354 | bodymovin::shapes::AnyShape::Trim(trim), 355 | frame_rate, 356 | position_scale, 357 | size_scale, 358 | )?)) 359 | } 360 | bodymovin::shapes::AnyShape::Repeater(repeater) => { 361 | println!("Repeater!"); 362 | Err(Error::NotAGroup) 363 | } 364 | bodymovin::shapes::AnyShape::RoundedCorners(rounded_corners) => { 365 | println!("RoundedCorners!"); 366 | Err(Error::NotAGroup) 367 | } 368 | bodymovin::shapes::AnyShape::Transform(transform) => { 369 | println!("Transform!"); 370 | Err(Error::NotAGroup) 371 | } 372 | // TODO: will this ever happen? 373 | // Note: Yes, it will 374 | _ => Err(Error::NotAGroup), 375 | } 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /src/after_effects/shapes/rect.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | after_effects::{ 3 | conv::{FromMultiDimensional, FromValue}, 4 | MaybeTrack, 5 | }, 6 | Animation, 7 | }; 8 | use std::time::Duration; 9 | use thiserror::Error; 10 | 11 | #[derive(Debug, Error)] 12 | pub enum RectError { 13 | #[error("Failed to convert `position`: {0}")] 14 | PositionInvalid(#[from] as FromMultiDimensional>::Error), 15 | #[error("Failed to convert `size`: {0}")] 16 | SizeInvalid(#[from] as FromMultiDimensional>::Error), 17 | #[error("Failed to convert `rounded_corners`: {0}")] 18 | RoundedCornersInvalid(#[from] ::Error), 19 | } 20 | 21 | #[derive(Debug)] 22 | pub struct Rect { 23 | pub direction: f64, 24 | pub position: MaybeTrack>, 25 | pub size: MaybeTrack>, 26 | pub rounded_corners: MaybeTrack, 27 | } 28 | 29 | impl Rect { 30 | pub(crate) fn from_bodymovin( 31 | rect: bodymovin::shapes::Rect, 32 | frame_rate: f64, 33 | position_scale: &Vec, 34 | size_scale: &Vec, 35 | ) -> Result { 36 | Ok(Self { 37 | direction: rect.direction, 38 | position: MaybeTrack::from_multi_dimensional( 39 | rect.position.scaled(&position_scale), 40 | frame_rate, 41 | )?, 42 | size: MaybeTrack::from_multi_dimensional(rect.size.scaled(&size_scale), frame_rate)?, 43 | rounded_corners: MaybeTrack::from_property(rect.rounded_corners, frame_rate) 44 | .map_err(RectError::RoundedCornersInvalid)?, 45 | }) 46 | } 47 | 48 | pub fn sample_rect(&self, elapsed: Duration) -> gee::Rect { 49 | gee::Rect::from_center(self.position.sample(elapsed), self.size.sample(elapsed)) 50 | } 51 | 52 | pub fn sample_rounded_corners(&self, elapsed: Duration) -> f64 { 53 | self.rounded_corners.sample(elapsed) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/after_effects/shapes/star.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | after_effects::{ 3 | conv::{FromMultiDimensional, FromValue}, 4 | MaybeTrack, 5 | }, 6 | Animation, 7 | }; 8 | use bodymovin::properties::ScalarKeyframe; 9 | use std::time::Duration; 10 | use thiserror::Error; 11 | 12 | #[derive(Debug, Error)] 13 | pub enum StarTypeError { 14 | #[error("Failed to convert `inner_radius`: {0}")] 15 | InnerRadiusInvalid(#[source] ::Error), 16 | #[error("Failed to convert `inner_roundness`: {0}")] 17 | InnerRoundnessInvalid(#[source] ::Error), 18 | #[error("Missing `inner_radius` or `inner_roundness` despite being a star-type star")] 19 | InnerFieldsMissing, 20 | } 21 | 22 | #[derive(Debug, Error)] 23 | pub enum StarError { 24 | #[error("Failed to convert `position`: {0}")] 25 | PositionInvalid(#[from] as FromMultiDimensional>::Error), 26 | #[error("Failed to convert `outer_radius`: {0}")] 27 | OuterRadiusInvalid(#[source] ::Error), 28 | #[error("Failed to convert `outer_roundness`: {0}")] 29 | OuterRoundnessInvalid(#[source] ::Error), 30 | #[error("Failed to convert `rotation`: {0}")] 31 | RotationInvalid(#[from] as FromValue>::Error), 32 | #[error("Failed to convert `points`: {0}")] 33 | PointsInvalid(#[from] ::Error), 34 | #[error("Failed to classify star: {0}")] 35 | TyInvalid(#[from] StarTypeError), 36 | } 37 | 38 | #[derive(Debug)] 39 | pub enum StarType { 40 | Star { 41 | // TODO: upstream this enum niceness 42 | inner_radius: MaybeTrack, 43 | inner_roundness: MaybeTrack, 44 | }, 45 | Polygon, 46 | } 47 | 48 | impl StarType { 49 | fn from_bodymovin( 50 | ty: bodymovin::shapes::StarType, 51 | inner_radius: Option>, 52 | inner_roundness: Option>, 53 | frame_rate: f64, 54 | ) -> Result { 55 | match ty { 56 | bodymovin::shapes::StarType::Star => inner_radius 57 | .zip(inner_roundness) 58 | .ok_or_else(|| StarTypeError::InnerFieldsMissing) 59 | .and_then(|(inner_radius, inner_roundness)| { 60 | Ok(Self::Star { 61 | inner_radius: MaybeTrack::from_value(inner_radius, frame_rate) 62 | .map_err(StarTypeError::InnerRadiusInvalid)?, 63 | inner_roundness: MaybeTrack::from_value(inner_roundness, frame_rate) 64 | .map_err(StarTypeError::InnerRoundnessInvalid)?, 65 | }) 66 | }), 67 | bodymovin::shapes::StarType::Polygon => { 68 | if inner_radius.is_some() || inner_roundness.is_some() { 69 | log::warn!("star specifies an inner radius or inner roundness despite being a polygon-type star") 70 | } 71 | Ok(Self::Polygon) 72 | } 73 | } 74 | } 75 | } 76 | 77 | #[derive(Debug)] 78 | pub struct Star { 79 | pub direction: f64, 80 | pub position: MaybeTrack>, 81 | pub outer_radius: MaybeTrack, 82 | pub outer_roundness: MaybeTrack, 83 | pub rotation: MaybeTrack>, 84 | pub points: MaybeTrack, 85 | pub ty: StarType, 86 | } 87 | 88 | impl Star { 89 | pub(crate) fn from_bodymovin( 90 | star: bodymovin::shapes::Star, 91 | frame_rate: f64, 92 | position_scale: &Vec, 93 | radius_scale: f64, 94 | ) -> Result { 95 | Ok(Self { 96 | direction: star.direction, 97 | position: MaybeTrack::from_multi_dimensional( 98 | star.position.scaled(position_scale), 99 | frame_rate, 100 | )?, 101 | outer_radius: MaybeTrack::from_property( 102 | star.outer_radius.scaled(radius_scale), 103 | frame_rate, 104 | ) 105 | .map_err(StarError::OuterRadiusInvalid)?, 106 | outer_roundness: MaybeTrack::from_property(star.outer_roundness, frame_rate) 107 | .map_err(StarError::OuterRoundnessInvalid)?, 108 | rotation: MaybeTrack::from_property(star.rotation, frame_rate)?, 109 | points: MaybeTrack::from_property(star.points, frame_rate)?, 110 | ty: StarType::from_bodymovin( 111 | star.ty, 112 | Some( 113 | star.inner_radius 114 | .unwrap_or_default() 115 | .scaled(radius_scale) 116 | .value, 117 | ), 118 | Some(star.inner_roundness.unwrap_or_default().value), 119 | frame_rate, 120 | )?, 121 | }) 122 | } 123 | 124 | pub fn sample_points(&self, elapsed: Duration) -> u32 { 125 | self.points.sample(elapsed) 126 | } 127 | 128 | pub fn sample_outer_circle(&self, elapsed: Duration) -> gee::Circle { 129 | gee::Circle::new( 130 | self.position.sample(elapsed), 131 | self.outer_radius.sample(elapsed), 132 | ) 133 | } 134 | 135 | pub fn sample_rotation(&self, elapsed: Duration) -> gee::Angle { 136 | self.rotation.sample(elapsed) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/after_effects/shapes/stroke.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | after_effects::{ 3 | conv::FromValue, 4 | shapes::{Color, GradientError, SolidError}, 5 | MaybeTrack, 6 | }, 7 | Animation as _, 8 | }; 9 | pub use bodymovin::helpers::{LineCap, LineJoin}; 10 | use std::time::Duration; 11 | use thiserror::Error; 12 | 13 | #[derive(Debug, Error)] 14 | pub enum StrokeError { 15 | #[error("Failed to convert `opacity`: {0}")] 16 | OpacityInvalid(#[source] ::Error), 17 | #[error("Failed to convert `width`: {0}")] 18 | WidthInvalid(#[source] ::Error), 19 | #[error("Failed to convert `color`: {0}")] 20 | ColorInvalid(#[from] SolidError), 21 | #[error("Failed to convert gradient: {0}")] 22 | GradientInvalid(#[from] GradientError), 23 | } 24 | 25 | #[derive(Debug)] 26 | pub struct Stroke { 27 | pub line_cap: LineCap, 28 | pub line_join: LineJoin, 29 | pub miter_limit: Option, 30 | pub opacity: MaybeTrack, 31 | pub width: MaybeTrack, 32 | pub color: Color, 33 | } 34 | 35 | impl Stroke { 36 | pub(crate) fn from_bodymovin( 37 | stroke: bodymovin::shapes::Stroke, 38 | frame_rate: f64, 39 | width_scale: f64, 40 | ) -> Result { 41 | Ok(Self { 42 | line_cap: stroke.line_cap, 43 | line_join: stroke.line_join, 44 | miter_limit: stroke.miter_limit, 45 | opacity: MaybeTrack::from_property(stroke.opacity, frame_rate) 46 | .map_err(StrokeError::OpacityInvalid)?, 47 | width: MaybeTrack::from_property(stroke.width.scaled(width_scale), frame_rate) 48 | .map_err(StrokeError::WidthInvalid)?, 49 | color: Color::from_bodymovin_solid(stroke.color, frame_rate)?, 50 | }) 51 | } 52 | 53 | pub(crate) fn from_bodymovin_with_gradient( 54 | stroke: bodymovin::shapes::GradientStroke, 55 | frame_rate: f64, 56 | ) -> Result { 57 | Ok(Self { 58 | line_cap: stroke.line_cap, 59 | line_join: stroke.line_join, 60 | miter_limit: stroke.miter_limit, 61 | opacity: MaybeTrack::from_property(stroke.opacity, frame_rate) 62 | .map_err(StrokeError::OpacityInvalid)?, 63 | // TODO: fix upstream naming inconsistency 64 | width: MaybeTrack::from_property(stroke.stroke_width, frame_rate) 65 | .map_err(StrokeError::WidthInvalid)?, 66 | color: Color::from_bodymovin_gradient( 67 | stroke.start_point, 68 | stroke.end_point, 69 | stroke.ty, 70 | Some( 71 | stroke 72 | .highlight_length 73 | .expect("Attempted to produce gradient of unknown length.") 74 | .value, 75 | ), 76 | Some(stroke.highlight_angle.unwrap_or_default().value), 77 | frame_rate, 78 | )?, 79 | }) 80 | } 81 | 82 | pub fn sample_width(&self, elapsed: Duration) -> f64 { 83 | self.width.sample(elapsed) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/after_effects/shapes/transform.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | after_effects::{ 3 | conv::{FromMultiDimensional, FromValue}, 4 | MaybeTrack, 5 | }, 6 | Animation, 7 | }; 8 | use bodymovin::properties::Value; 9 | use core::fmt::Debug; 10 | use gee::{Angle, Size, Vector}; 11 | use std::time::Duration; 12 | use thiserror::Error; 13 | 14 | #[derive(Debug, Error)] 15 | pub enum TransformError { 16 | #[error("Failed to convert `anchor_point`: {0}")] 17 | AnchorPointInvalid(#[source] as FromMultiDimensional>::Error), 18 | #[error("Failed to convert `position`: {0}")] 19 | PositionInvalid(#[source] as FromMultiDimensional>::Error), 20 | #[error("Failed to convert `scale`: {0}")] 21 | ScaleInvalid(#[source] as FromMultiDimensional>::Error), 22 | #[error("Failed to convert `rotation`: {0}")] 23 | RotationInvalid(#[source] as FromValue>::Error), 24 | #[error("Failed to convert `opacity`: {0}")] 25 | OpacityInvalid(#[source] ::Error), 26 | #[error("Failed to convert `skew`: {0}")] 27 | SkewInvalid(#[source] as FromValue>::Error), 28 | #[error("Failed to convert `skew_axis`: {0}")] 29 | SkewAxisInvalid(#[source] ::Error), 30 | } 31 | 32 | pub struct Transform { 33 | anchor_point: MaybeTrack>, 34 | position: MaybeTrack>, 35 | scale: MaybeTrack>, 36 | rotation: MaybeTrack>, 37 | opacity: MaybeTrack, 38 | skew: MaybeTrack>, 39 | skew_axis: MaybeTrack, 40 | } 41 | 42 | impl Transform { 43 | pub fn identity() -> Self { 44 | Self { 45 | anchor_point: MaybeTrack::Fixed(Vector::default()), 46 | position: MaybeTrack::Fixed(Vector::default()), 47 | scale: MaybeTrack::Fixed(Vector::one()), 48 | rotation: MaybeTrack::Fixed(Angle::default()), 49 | opacity: MaybeTrack::Fixed(1.0), 50 | skew: MaybeTrack::Fixed(Angle::default()), 51 | skew_axis: MaybeTrack::Fixed(0.0), 52 | } 53 | } 54 | 55 | pub(crate) fn from_bodymovin_helper( 56 | transform: bodymovin::helpers::Transform, 57 | frame_rate: f64, 58 | position_scale: &Vec, 59 | ) -> Result { 60 | // TODO: we're not handling px/py/pz 61 | Ok(Self { 62 | anchor_point: MaybeTrack::from_multi_dimensional( 63 | transform.anchor_point.scaled(&position_scale), 64 | frame_rate, 65 | ) 66 | .map_err(TransformError::AnchorPointInvalid)?, 67 | position: MaybeTrack::from_multi_dimensional( 68 | transform.position.scaled(&position_scale), 69 | frame_rate, 70 | ) 71 | .map_err(TransformError::PositionInvalid)?, 72 | scale: MaybeTrack::from_multi_dimensional(transform.scale, frame_rate) 73 | .map_err(TransformError::ScaleInvalid)?, 74 | rotation: MaybeTrack::from_property(transform.rotation, frame_rate) 75 | .map_err(TransformError::RotationInvalid)?, 76 | opacity: MaybeTrack::from_property(transform.opacity, frame_rate) 77 | .map_err(TransformError::OpacityInvalid)?, 78 | skew: MaybeTrack::from_property(transform.skew, frame_rate) 79 | .map_err(TransformError::SkewInvalid)?, 80 | skew_axis: MaybeTrack::from_property(transform.skew_axis, frame_rate) 81 | .map_err(TransformError::SkewAxisInvalid)?, 82 | }) 83 | } 84 | 85 | pub(crate) fn from_bodymovin_shape( 86 | transform: bodymovin::shapes::Transform, 87 | frame_rate: f64, 88 | position_scale: &Vec, 89 | ) -> Result { 90 | Ok(Self { 91 | anchor_point: MaybeTrack::from_multi_dimensional( 92 | transform.anchor_point.scaled(&position_scale), 93 | frame_rate, 94 | ) 95 | .map_err(TransformError::AnchorPointInvalid)?, 96 | position: MaybeTrack::from_multi_dimensional( 97 | transform.position.scaled(&position_scale), 98 | frame_rate, 99 | ) 100 | .map_err(TransformError::PositionInvalid)?, 101 | scale: MaybeTrack::from_multi_dimensional(transform.scale, frame_rate) // Does scale need to be size_scaled? It's all relative, right? So if the underlying shape's size has been modified, we're good to go? 102 | .map_err(TransformError::ScaleInvalid)?, 103 | rotation: MaybeTrack::from_property(transform.rotation, frame_rate) 104 | .map_err(TransformError::RotationInvalid)?, 105 | opacity: MaybeTrack::from_property(transform.opacity, frame_rate) 106 | .map_err(TransformError::OpacityInvalid)?, 107 | skew: MaybeTrack::from_property(transform.skew, frame_rate) 108 | .map_err(TransformError::SkewInvalid)?, 109 | skew_axis: MaybeTrack::from_property(transform.skew_axis, frame_rate) 110 | .map_err(TransformError::SkewAxisInvalid)?, 111 | }) 112 | } 113 | 114 | pub fn sample_transform(&self, elapsed: Duration) -> gee::Transform { 115 | let anchor_point = self.anchor_point.sample(elapsed); 116 | let translation = self.position.sample(elapsed); 117 | let rotation = self.rotation.sample(elapsed); 118 | let skew = self.skew.sample(elapsed); 119 | // scale is a percentage! 120 | let scale = self.scale.sample(elapsed) / gee::Vector::uniform(100.0); 121 | let transform = gee::DecomposedTransform { 122 | translation, 123 | rotation, 124 | skew, 125 | scale, 126 | }; 127 | log::info!("sampled transform components: `{:#?}`", transform,); 128 | // TODO: what to do with skew_axis? 129 | gee::Transform::from_decomposed(transform).pre_translate_vector(-anchor_point) 130 | } 131 | 132 | pub fn sample_opacity(&self, elapsed: Duration) -> f64 { 133 | let opacity = self.opacity.sample(elapsed) / 100.0; 134 | log::info!("sampled opacity {:?}", opacity); 135 | opacity 136 | } 137 | } 138 | 139 | impl Debug for Transform { 140 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 141 | match &self.position { 142 | MaybeTrack::Animated(track) => { 143 | write!(f, "Position:\n{:?}\n", track) 144 | .expect("Error when printing Transform Position information!"); 145 | } 146 | MaybeTrack::Fixed(value) => write!(f, "Position:\n{:?}\n", value) 147 | .expect("Error when printing Transform Position information!"), 148 | }; 149 | 150 | match &self.scale { 151 | MaybeTrack::Animated(track) => { 152 | write!(f, "Scale:\n{:?}\n", track) 153 | .expect("Error when printing Transform Scale information!"); 154 | } 155 | _ => (), 156 | }; 157 | 158 | match &self.rotation { 159 | MaybeTrack::Animated(track) => { 160 | write!(f, "Rotation\n{:?}\n", track) 161 | .expect("Error when printing Transform Rotation information!"); 162 | } 163 | _ => (), 164 | }; 165 | 166 | match &self.skew { 167 | MaybeTrack::Animated(track) => { 168 | write!(f, "Skew:\n{:?}\n", track) 169 | .expect("Error when printing Transform Skew information!"); 170 | } 171 | _ => (), 172 | }; 173 | 174 | match &self.opacity { 175 | MaybeTrack::Animated(track) => { 176 | write!(f, "Opacity:\n{:?}\n", track) 177 | .expect("Error when printing Transform Opacity information!"); 178 | } 179 | _ => (), 180 | }; 181 | 182 | write!(f, "") 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/combinators/chain.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Animation, BoundedAnimation}; 2 | use std::{marker::PhantomData, time::Duration}; 3 | 4 | /// See [`BoundedAnimation::chain`] for details. 5 | #[derive(Debug)] 6 | pub struct Chain 7 | where 8 | A: BoundedAnimation, 9 | B: Animation, 10 | V: Animatable, 11 | { 12 | a: A, 13 | b: B, 14 | _marker: PhantomData, 15 | } 16 | 17 | impl Animation for Chain 18 | where 19 | A: BoundedAnimation, 20 | B: Animation, 21 | V: Animatable, 22 | { 23 | fn sample(&self, elapsed: Duration) -> V { 24 | let inflection = self.a.duration(); 25 | if elapsed < inflection { 26 | self.a.sample(elapsed) 27 | } else { 28 | self.b.sample(elapsed - inflection) 29 | } 30 | } 31 | } 32 | 33 | impl BoundedAnimation for Chain 34 | where 35 | A: BoundedAnimation, 36 | B: BoundedAnimation, 37 | V: Animatable, 38 | { 39 | fn duration(&self) -> Duration { 40 | self.a.duration() + self.b.duration() 41 | } 42 | } 43 | 44 | impl Chain 45 | where 46 | A: BoundedAnimation, 47 | B: Animation, 48 | V: Animatable, 49 | { 50 | pub(crate) fn new(a: A, b: B) -> Self { 51 | Self { 52 | a, 53 | b, 54 | _marker: PhantomData, 55 | } 56 | } 57 | 58 | pub fn percent_elapsed_a(&self, elapsed: Duration) -> f64 { 59 | self.a.percent_elapsed(elapsed) 60 | } 61 | } 62 | 63 | impl Chain 64 | where 65 | A: BoundedAnimation, 66 | B: BoundedAnimation, 67 | V: Animatable, 68 | { 69 | pub fn percent_elapsed_b(&self, elapsed: Duration) -> f64 { 70 | (elapsed < self.a.duration()) 71 | .then(|| 0.0) 72 | .unwrap_or_else(|| self.b.percent_elapsed(elapsed - self.a.duration())) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/combinators/cutoff.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Animation, BoundedAnimation}; 2 | use std::{marker::PhantomData, time::Duration}; 3 | 4 | /// See [`Animation::cutoff`] for details. 5 | #[derive(Debug)] 6 | pub struct Cutoff 7 | where 8 | A: Animation, 9 | V: Animatable, 10 | { 11 | anim: A, 12 | cutoff: Duration, 13 | _marker: PhantomData, 14 | } 15 | 16 | impl Animation for Cutoff 17 | where 18 | A: Animation, 19 | V: Animatable, 20 | { 21 | fn sample(&self, elapsed: Duration) -> V { 22 | self.anim.sample(if elapsed < self.cutoff { 23 | elapsed 24 | } else { 25 | self.cutoff 26 | }) 27 | } 28 | } 29 | 30 | impl BoundedAnimation for Cutoff 31 | where 32 | A: Animation, 33 | V: Animatable, 34 | { 35 | fn duration(&self) -> Duration { 36 | self.cutoff 37 | } 38 | } 39 | 40 | impl Cutoff 41 | where 42 | A: Animation, 43 | V: Animatable, 44 | { 45 | pub(crate) fn new(anim: A, cutoff: Duration) -> Self { 46 | Self { 47 | anim, 48 | cutoff, 49 | _marker: PhantomData, 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/combinators/cycle.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Animation, BoundedAnimation}; 2 | use std::{marker::PhantomData, time::Duration}; 3 | 4 | /// See [`BoundedAnimation::cycle`] for details. 5 | #[derive(Debug)] 6 | pub struct Cycle 7 | where 8 | A: BoundedAnimation, 9 | V: Animatable, 10 | { 11 | anim: A, 12 | _marker: PhantomData, 13 | } 14 | 15 | impl Animation for Cycle 16 | where 17 | A: BoundedAnimation, 18 | V: Animatable, 19 | { 20 | fn sample(&self, elapsed: Duration) -> V { 21 | let progress = elapsed.as_secs_f64() % self.anim.duration().as_secs_f64(); 22 | self.anim.sample(Duration::from_secs_f64(progress)) 23 | } 24 | } 25 | 26 | impl Cycle 27 | where 28 | A: BoundedAnimation, 29 | V: Animatable, 30 | { 31 | pub(crate) fn new(anim: A) -> Self { 32 | Self { 33 | anim, 34 | _marker: PhantomData, 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/combinators/interrupt.rs: -------------------------------------------------------------------------------- 1 | use crate::{spline::bezier_ease::BezierEase, Animatable, Animation, BoundedAnimation}; 2 | use std::time::Duration; 3 | 4 | const SAMPLE_DELTA: f64 = 1e-5; 5 | 6 | #[derive(Debug)] 7 | pub struct Interrupt 8 | where 9 | A: Animation, 10 | B: Animation, 11 | V: Animatable, 12 | { 13 | a: Option, 14 | a_interrupt: Linear, 15 | b: B, 16 | interrupt_t: Duration, 17 | transition_t: Duration, 18 | pre_multiplied: bool, 19 | } 20 | 21 | impl Animation for Interrupt 22 | where 23 | A: Animation, 24 | B: Animation, 25 | V: Animatable, 26 | { 27 | fn sample(&self, elapsed: Duration) -> V { 28 | if elapsed >= self.interrupt_t { 29 | let elapsed = elapsed - self.interrupt_t; 30 | 31 | // relative change from interrupt.value for each animation 32 | let a_contribution = self 33 | .a_interrupt 34 | .sample(elapsed) 35 | .zip_map(self.a_interrupt.value, |a, v| a - v); 36 | let b_contribution = self 37 | .b 38 | .sample(elapsed) 39 | .zip_map(self.a_interrupt.value, |b, v| b - v); 40 | 41 | // calculate ease 42 | let transition_percent_elapsed = 43 | ((elapsed.as_secs_f64()) / self.transition_t.as_secs_f64()).min(1.0); 44 | let ease = BezierEase::ease_in_out().ease(transition_percent_elapsed); 45 | 46 | // blend a_contribution and b_contribution 47 | let blended_contributions = a_contribution.zip_map(b_contribution, |a, b| { 48 | let ac = a * V::cast_component(1.0 - ease); 49 | let bc = if self.pre_multiplied { 50 | b 51 | } else { 52 | b * V::cast_component(ease) 53 | }; 54 | ac + bc 55 | }); 56 | 57 | self.a_interrupt 58 | .value 59 | .zip_map(blended_contributions, |v, b| v + b) 60 | } else { 61 | if let Some(animation) = &self.a { 62 | animation.sample(elapsed) 63 | } else { 64 | self.a_interrupt.sample(elapsed) 65 | } 66 | } 67 | } 68 | } 69 | 70 | impl BoundedAnimation for Interrupt 71 | where 72 | A: Animation, 73 | B: BoundedAnimation, 74 | V: Animatable, 75 | { 76 | fn duration(&self) -> Duration { 77 | self.interrupt_t + self.b.duration() 78 | } 79 | } 80 | 81 | impl Interrupt 82 | where 83 | A: Animation, 84 | B: Animation, 85 | V: Animatable, 86 | { 87 | pub fn new(a: A, b: B, interrupt_t: Duration, transition_t: Duration) -> Self { 88 | Self { 89 | a: None, 90 | ..Self::reversible(a, b, interrupt_t, transition_t) 91 | } 92 | } 93 | 94 | pub fn reversible(a: A, b: B, interrupt_t: Duration, transition_t: Duration) -> Self { 95 | let interrupt_v = a.sample(interrupt_t); 96 | let sample_duration = Duration::from_secs_f64(SAMPLE_DELTA); 97 | 98 | let velocity = a 99 | .sample(interrupt_t + sample_duration) 100 | .zip_map( 101 | a.sample( 102 | (interrupt_t < sample_duration) 103 | .then(|| Duration::ZERO) 104 | .unwrap_or_else(|| interrupt_t - Duration::from_secs_f64(SAMPLE_DELTA)), 105 | ), 106 | |n, p| n - p, 107 | ) 108 | .map(|a| a * V::cast_component(0.5 / SAMPLE_DELTA)); 109 | 110 | let linear = Linear::new(interrupt_v, velocity); 111 | 112 | let pre_multiplied = b.sample(Duration::ZERO).distance_to(interrupt_v) == 0.0; 113 | 114 | Self { 115 | a: Some(a), 116 | a_interrupt: linear, 117 | b, 118 | interrupt_t, 119 | transition_t, 120 | pre_multiplied, 121 | } 122 | } 123 | } 124 | 125 | // Linear animation from a point w/ a vector 126 | #[derive(Debug)] 127 | pub struct Linear { 128 | pub value: V, 129 | dt_value: V, 130 | } 131 | 132 | impl Linear 133 | where 134 | V: Animatable, 135 | { 136 | fn new(value: V, dt_value: V) -> Self { 137 | Self { value, dt_value } 138 | } 139 | } 140 | 141 | impl Animation for Linear 142 | where 143 | V: Animatable, 144 | { 145 | fn sample(&self, elapsed: Duration) -> V { 146 | self.value.zip_map(self.dt_value, |v, dvdt| { 147 | v + dvdt * V::cast_component(elapsed.as_secs_f64()) 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/combinators/mod.rs: -------------------------------------------------------------------------------- 1 | mod chain; 2 | mod cutoff; 3 | mod cycle; 4 | mod interrupt; 5 | mod rev; 6 | 7 | pub use self::{chain::*, cutoff::*, cycle::*, interrupt::*, rev::*}; 8 | -------------------------------------------------------------------------------- /src/combinators/rev.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Animation, BoundedAnimation}; 2 | use std::{marker::PhantomData, time::Duration}; 3 | 4 | /// See [`BoundedAnimation::rev`] for details. 5 | #[derive(Debug)] 6 | pub struct Rev 7 | where 8 | A: BoundedAnimation, 9 | V: Animatable, 10 | { 11 | anim: A, 12 | _marker: PhantomData, 13 | } 14 | 15 | impl Animation for Rev 16 | where 17 | A: BoundedAnimation, 18 | V: Animatable, 19 | { 20 | fn sample(&self, elapsed: Duration) -> V { 21 | self.anim.sample( 22 | (elapsed < self.duration()) 23 | .then(|| self.duration() - elapsed) 24 | .unwrap_or_else(|| Duration::ZERO), 25 | ) 26 | } 27 | } 28 | 29 | impl BoundedAnimation for Rev 30 | where 31 | A: BoundedAnimation, 32 | V: Animatable, 33 | { 34 | fn duration(&self) -> Duration { 35 | self.anim.duration() 36 | } 37 | } 38 | 39 | impl Rev 40 | where 41 | A: BoundedAnimation, 42 | V: Animatable, 43 | { 44 | pub(crate) fn new(anim: A) -> Self { 45 | Self { 46 | anim, 47 | _marker: PhantomData, 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/component_wise.rs: -------------------------------------------------------------------------------- 1 | use gee::en; 2 | 3 | /// A value that has zero or more numeric components. 4 | pub trait ComponentWise: Sized { 5 | type Component: en::Num; 6 | 7 | /// Runs `f` on each component, replacing the component with the return 8 | /// value. 9 | /// 10 | /// Not truly map, since it can't change the component type. 11 | fn map(self, f: F) -> Self 12 | where 13 | F: Fn(Self::Component) -> Self::Component; 14 | 15 | /// Runs `f` on each component paired with the corresponding component from 16 | /// `other`, replacing the component with the return value. 17 | /// 18 | /// This is the basic building block for generic component-wise operations. 19 | fn zip_map(self, other: Self, f: F) -> Self 20 | where 21 | F: Fn(Self::Component, Self::Component) -> Self::Component; 22 | 23 | /// Component-wise addition. 24 | fn add(self, other: Self) -> Self { 25 | self.zip_map(other, std::ops::Add::add) 26 | } 27 | 28 | /// Component-wise subtraction. 29 | fn sub(self, other: Self) -> Self { 30 | self.zip_map(other, std::ops::Sub::sub) 31 | } 32 | 33 | /// Convenience for casting a number to the component type. 34 | fn cast_component(other: T) -> Self::Component { 35 | en::cast(other) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/constant.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Animation, BoundedAnimation}; 2 | use std::time::Duration; 3 | 4 | /// An animation that never changes. 5 | /// 6 | /// This likely isn't very useful, besides the potential as a default. Its main 7 | /// purpose is to demonstrate the properties of the most fundamental animation 8 | /// possible. 9 | #[derive(Debug)] 10 | #[repr(transparent)] 11 | pub struct Constant 12 | where 13 | V: Animatable, 14 | { 15 | value: V, 16 | } 17 | 18 | impl Animation for Constant 19 | where 20 | V: Animatable, 21 | { 22 | fn sample(&self, _elapsed: Duration) -> V { 23 | self.value 24 | } 25 | } 26 | 27 | impl BoundedAnimation for Constant 28 | where 29 | V: Animatable, 30 | { 31 | fn duration(&self) -> Duration { 32 | // The `duration` is the period over which the animation changes, and 33 | // since `Constant` never changes, this is just zero. 34 | Duration::ZERO 35 | } 36 | } 37 | 38 | impl Constant 39 | where 40 | V: Animatable, 41 | { 42 | pub fn new(value: V) -> Self { 43 | Self { value } 44 | } 45 | } 46 | 47 | impl Default for Constant 48 | where 49 | V: Animatable + Default, 50 | { 51 | fn default() -> Self { 52 | Self::new(V::default()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ease.rs: -------------------------------------------------------------------------------- 1 | use crate::spline::bezier_ease::BezierEase; 2 | use std::fmt::Debug; 3 | 4 | pub type EaseFunction = fn(f64) -> f64; 5 | 6 | #[derive(Clone, Copy)] 7 | pub enum Ease { 8 | Bezier(BezierEase), 9 | Function(EaseFunction), 10 | } 11 | 12 | impl Debug for Ease { 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 14 | match self { 15 | Ease::Bezier(ease) => write!(f, "Ease::Bezier({:?})", ease), 16 | Ease::Function(_) => write!(f, "Ease::Function(...)"), 17 | } 18 | } 19 | } 20 | 21 | impl Ease { 22 | pub fn ease(&self, t: f64) -> f64 { 23 | match self { 24 | Ease::Bezier(bezier) => bezier.ease(t), 25 | Ease::Function(ease) => ease(t), 26 | } 27 | } 28 | 29 | pub fn in_sine() -> Self { 30 | Self::Function(in_sine) 31 | } 32 | pub fn out_sine() -> Self { 33 | Self::Function(out_sine) 34 | } 35 | pub fn in_out_sine() -> Self { 36 | Self::Function(in_out_sine) 37 | } 38 | 39 | pub fn in_quad() -> Self { 40 | Self::Function(in_quad) 41 | } 42 | pub fn out_quad() -> Self { 43 | Self::Function(out_quad) 44 | } 45 | pub fn in_out_quad() -> Self { 46 | Self::Function(in_out_quad) 47 | } 48 | 49 | pub fn in_cubic() -> Self { 50 | Self::Function(in_cubic) 51 | } 52 | pub fn out_cubic() -> Self { 53 | Self::Function(out_cubic) 54 | } 55 | pub fn in_out_cubic() -> Self { 56 | Self::Function(in_out_cubic) 57 | } 58 | 59 | pub fn in_quart() -> Self { 60 | Self::Function(in_quart) 61 | } 62 | pub fn out_quart() -> Self { 63 | Self::Function(out_quart) 64 | } 65 | pub fn in_out_quart() -> Self { 66 | Self::Function(in_out_quart) 67 | } 68 | 69 | pub fn in_quint() -> Self { 70 | Self::Function(in_quint) 71 | } 72 | pub fn out_quint() -> Self { 73 | Self::Function(out_quint) 74 | } 75 | pub fn in_out_quint() -> Self { 76 | Self::Function(in_out_quint) 77 | } 78 | 79 | pub fn in_expo() -> Self { 80 | Self::Function(in_expo) 81 | } 82 | pub fn out_expo() -> Self { 83 | Self::Function(out_expo) 84 | } 85 | pub fn in_out_expo() -> Self { 86 | Self::Function(in_out_expo) 87 | } 88 | 89 | pub fn in_circle() -> Self { 90 | Self::Function(in_circle) 91 | } 92 | pub fn out_circle() -> Self { 93 | Self::Function(out_circle) 94 | } 95 | pub fn in_out_circle() -> Self { 96 | Self::Function(in_out_circle) 97 | } 98 | 99 | pub fn in_back() -> Self { 100 | Self::Function(in_back) 101 | } 102 | pub fn out_back() -> Self { 103 | Self::Function(out_back) 104 | } 105 | pub fn in_out_back() -> Self { 106 | Self::Function(in_out_back) 107 | } 108 | 109 | pub fn in_elastic() -> Self { 110 | Self::Function(in_elastic) 111 | } 112 | pub fn out_elastic() -> Self { 113 | Self::Function(out_elastic) 114 | } 115 | pub fn in_out_elastic() -> Self { 116 | Self::Function(in_out_elastic) 117 | } 118 | 119 | pub fn in_bounce() -> Self { 120 | Self::Function(in_bounce) 121 | } 122 | pub fn out_bounce() -> Self { 123 | Self::Function(out_bounce) 124 | } 125 | pub fn in_out_bounce() -> Self { 126 | Self::Function(in_out_bounce) 127 | } 128 | 129 | pub fn none() -> Self { 130 | Self::Function(identity) 131 | } 132 | pub fn lazy() -> Self { 133 | Self::Function(lazy) 134 | } 135 | } 136 | 137 | // All of the eases shown here are included for your convenience: https://easings.net/ 138 | 139 | // Sine Eases ================================================================= 140 | 141 | fn in_sine(t: f64) -> f64 { 142 | 1.0 - f64::cos(t * std::f64::consts::FRAC_PI_2) 143 | } 144 | 145 | fn out_sine(t: f64) -> f64 { 146 | f64::sin(t * std::f64::consts::FRAC_PI_2) 147 | } 148 | 149 | fn in_out_sine(t: f64) -> f64 { 150 | -(f64::cos(t * std::f64::consts::PI) - 1.0) / 2.0 151 | } 152 | 153 | // Exponential Eases ========================================================== 154 | 155 | fn in_exponential(t: f64, n: i32) -> f64 { 156 | t.powi(n) 157 | } 158 | 159 | fn out_exponential(t: f64, n: i32) -> f64 { 160 | 1.0 - in_exponential(1.0 - t, n) 161 | } 162 | 163 | fn in_out_exponential(t: f64, n: i32) -> f64 { 164 | (t < 0.5) 165 | .then(|| f64::powi(2.0, n - 1) * in_exponential(t, n)) 166 | .unwrap_or_else(|| 1.0 - (-2.0 * t + 2.0).powi(n) / 2.0) 167 | } 168 | 169 | // Quad Eases ================================================================= 170 | 171 | fn in_quad(t: f64) -> f64 { 172 | in_exponential(t, 2) 173 | } 174 | 175 | fn out_quad(t: f64) -> f64 { 176 | out_exponential(t, 2) 177 | } 178 | 179 | fn in_out_quad(t: f64) -> f64 { 180 | in_out_exponential(t, 2) 181 | } 182 | 183 | // Cubic Eases ================================================================ 184 | 185 | fn in_cubic(t: f64) -> f64 { 186 | in_exponential(t, 3) 187 | } 188 | 189 | fn out_cubic(t: f64) -> f64 { 190 | out_exponential(t, 3) 191 | } 192 | 193 | fn in_out_cubic(t: f64) -> f64 { 194 | in_out_exponential(t, 3) 195 | } 196 | 197 | // Quart Eases ================================================================ 198 | 199 | fn in_quart(t: f64) -> f64 { 200 | in_exponential(t, 4) 201 | } 202 | 203 | fn out_quart(t: f64) -> f64 { 204 | out_exponential(t, 4) 205 | } 206 | 207 | fn in_out_quart(t: f64) -> f64 { 208 | in_out_exponential(t, 4) 209 | } 210 | 211 | // Quint Eases ================================================================= 212 | 213 | fn in_quint(t: f64) -> f64 { 214 | in_exponential(t, 5) 215 | } 216 | 217 | fn out_quint(t: f64) -> f64 { 218 | out_exponential(t, 5) 219 | } 220 | 221 | fn in_out_quint(t: f64) -> f64 { 222 | in_out_exponential(t, 5) 223 | } 224 | 225 | // Expo Eases ================================================================= 226 | 227 | fn in_expo(t: f64) -> f64 { 228 | 2.0_f64.powf(10.0 * t - 10.0) 229 | } 230 | 231 | fn out_expo(t: f64) -> f64 { 232 | 2.0_f64.powf(-10.0 * t) 233 | } 234 | 235 | fn in_out_expo(t: f64) -> f64 { 236 | (t == 0.0 || t == 1.0).then(|| t).unwrap_or_else(|| { 237 | if t < 0.5 { 238 | 2.0_f64.powf(20.0 * t - 10.0) / 2.0 239 | } else { 240 | (2.0 - 2.0_f64.powf(-20.0 * t + 10.0)) / 2.0 241 | } 242 | }) 243 | } 244 | 245 | // Circle Eases =============================================================== 246 | 247 | fn in_circle(t: f64) -> f64 { 248 | 1.0 - (1.0 - t.powi(2)).sqrt() 249 | } 250 | 251 | fn out_circle(t: f64) -> f64 { 252 | (1.0 - t.powi(2)).sqrt() 253 | } 254 | 255 | fn in_out_circle(t: f64) -> f64 { 256 | (t < 0.5) 257 | .then(|| (1.0 - (1.0 - (2.0 * t).powi(2)).sqrt()) / 2.0) 258 | .unwrap_or_else(|| ((1.0 - (-2.0 * t + 2.0).powi(2)).sqrt() + 1.0) / 2.0) 259 | } 260 | 261 | // Back Eases ================================================================= 262 | 263 | fn in_back(t: f64) -> f64 { 264 | 2.70158 * t.powi(3) - 1.70158 * t.powi(2) 265 | } 266 | 267 | fn out_back(t: f64) -> f64 { 268 | 1.0 + 2.70158 * (t - 1.0).powi(3) + 1.70158 * (t - 1.0).powi(2) 269 | } 270 | 271 | fn in_out_back(t: f64) -> f64 { 272 | let c = 2.5949095; 273 | 274 | (t < 0.5) 275 | .then(|| ((t * 2.0).powi(2) * ((c + 1.0) * 2.0 * t - c)) / 2.0) 276 | .unwrap_or_else(|| { 277 | ((2.0 * t - 2.0).powi(2) * ((c + 1.0) * (t * 2.0 - 2.0) + c) + 2.0) / 2.0 278 | }) 279 | } 280 | 281 | // Elastic Eases ============================================================== 282 | 283 | fn in_elastic(t: f64) -> f64 { 284 | let c = std::f64::consts::TAU / 3.0; 285 | (t == 0.0 || t == 1.0) 286 | .then(|| t) 287 | .unwrap_or_else(|| -f64::powf(2.0, 10.0 * t - 10.0) * f64::sin((10.0 * t - 10.75) * c)) 288 | } 289 | 290 | fn out_elastic(t: f64) -> f64 { 291 | let c = std::f64::consts::TAU / 3.0; 292 | (t == 0.0 || t == 1.0) 293 | .then(|| t) 294 | .unwrap_or_else(|| -f64::powf(2.0, t * -10.0) * f64::sin((10.0 * t - 0.75) * c)) 295 | } 296 | 297 | fn in_out_elastic(t: f64) -> f64 { 298 | let c = std::f64::consts::TAU / 4.5; 299 | (t == 0.0 || t == 1.0).then(|| t).unwrap_or_else(|| { 300 | if t < 0.5 { 301 | (-f64::powf(2.0, 20.0 * t - 10.0) * f64::sin((20.0 * t - 11.125) * c)) / 2.0 302 | } else { 303 | (f64::powf(2.0, -20.0 * t + 10.0) * f64::sin((20.0 * t - 11.125) * c)) / 2.0 + 1.0 304 | } 305 | }) 306 | } 307 | 308 | // Bounce Eases =============================================================== 309 | 310 | fn in_bounce(t: f64) -> f64 { 311 | 1.0 - out_bounce(1.0 - t) 312 | } 313 | 314 | fn out_bounce(t: f64) -> f64 { 315 | let n = 7.5625; 316 | let d = 2.75; 317 | 318 | if t < 1.0 / d { 319 | n * t.powi(2) 320 | } else if t < 2.0 / d { 321 | n * (t - (1.5 / d)) * (t - (1.5 / d)) + 0.75 322 | } else if t < 2.5 / d { 323 | n * (t - (2.25 / d)) * (t - (2.25 / d)) + 0.9375 324 | } else { 325 | n * (t - (2.625 / d)) * (t - (2.625 / d)) + 0.984375 326 | } 327 | } 328 | 329 | fn in_out_bounce(t: f64) -> f64 { 330 | (t < 0.5) 331 | .then(|| (1.0 - out_bounce(1.0 - 2.0 * t)) / 2.0) 332 | .unwrap_or_else(|| (1.0 + out_bounce(2.0 * t - 1.0)) / 2.0) 333 | } 334 | 335 | // Miscellaneous ============================================================== 336 | 337 | fn identity(t: f64) -> f64 { 338 | t 339 | } 340 | 341 | fn lazy(t: f64) -> f64 { 342 | (t < 0.5).then(|| 0.0).unwrap_or_else(|| 2.0 * (t - 0.5)) 343 | } 344 | -------------------------------------------------------------------------------- /src/function.rs: -------------------------------------------------------------------------------- 1 | use crate::{Animatable, Animation}; 2 | use std::{ 3 | fmt::{self, Debug}, 4 | time::Duration, 5 | }; 6 | 7 | /// An animation that just calls a function. 8 | pub struct Function 9 | where 10 | F: Fn(Duration) -> V, 11 | V: Animatable, 12 | { 13 | function: F, 14 | } 15 | 16 | impl Debug for Function 17 | where 18 | F: Fn(Duration) -> V, 19 | V: Animatable, 20 | { 21 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 22 | f.debug_struct("Function").field("function", &"..").finish() 23 | } 24 | } 25 | 26 | impl Animation for Function 27 | where 28 | F: Fn(Duration) -> V, 29 | V: Animatable, 30 | { 31 | fn sample(&self, elapsed: Duration) -> V { 32 | (self.function)(elapsed) 33 | } 34 | } 35 | 36 | impl Function 37 | where 38 | F: Fn(Duration) -> V, 39 | V: Animatable, 40 | { 41 | pub fn new(function: F) -> Self { 42 | Self { function } 43 | } 44 | } 45 | 46 | impl From for Function 47 | where 48 | F: Fn(Duration) -> V, 49 | V: Animatable, 50 | { 51 | fn from(function: F) -> Self { 52 | Self::new(function) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/interval.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ease::Ease, 3 | lerp_components, 4 | spline::{ 5 | bezier::{cubic_bezier, fixed_bezier}, 6 | bezier_ease::BezierEase, 7 | bezier_path::BezierPath, 8 | spline_ease, SplineMap, 9 | }, 10 | Animatable, Animation, BoundedAnimation, 11 | }; 12 | use core::fmt::Debug; 13 | use std::time::Duration; 14 | 15 | // A half-interval 16 | #[derive(Copy, Clone, Debug)] 17 | pub struct Frame { 18 | pub offset: Duration, 19 | pub value: V, 20 | } 21 | 22 | impl Frame { 23 | pub fn new(offset: Duration, value: V) -> Self { 24 | Self { offset, value } 25 | } 26 | } 27 | 28 | #[derive(Clone)] 29 | pub struct Interval { 30 | pub start: Duration, 31 | pub end: Duration, 32 | pub from: V, 33 | pub to: V, 34 | pub ease: Option, 35 | pub path: Option>, 36 | pub reticulated_spline: Option, 37 | } 38 | 39 | #[derive(Clone, Debug)] 40 | pub struct InspectInterval { 41 | pub start: Duration, 42 | pub end: Duration, 43 | pub ease: Vec<(f64, f64)>, 44 | pub path: Vec, 45 | pub reticulated_spline: Option>, 46 | } 47 | 48 | impl Interval { 49 | pub fn new( 50 | start: Duration, 51 | end: Duration, 52 | from: V, 53 | to: V, 54 | ease: Option, 55 | path: Option>, 56 | reticulated_spline: Option, 57 | ) -> Self { 58 | Self { 59 | start, 60 | end, 61 | from, 62 | to, 63 | ease, 64 | path, 65 | reticulated_spline, 66 | } 67 | } 68 | 69 | pub fn eased(a: Frame, b: Frame, ease: Option) -> Self { 70 | Self::new(a.offset, b.offset, a.value, b.value, ease, None, None) 71 | } 72 | 73 | pub fn linear(a: Frame, b: Frame) -> Self { 74 | Self::new(a.offset, b.offset, a.value, b.value, None, None, None) 75 | } 76 | 77 | pub fn hold(value: V, duration: Duration) -> Self { 78 | Self::new(Duration::ZERO, duration, value, value, None, None, None) 79 | } 80 | 81 | pub fn from_values(duration: Duration, from: V, to: V, ease: Option) -> Self { 82 | Self::new(Duration::ZERO, duration, from, to, ease, None, None) 83 | } 84 | 85 | pub fn percent_elapsed(&self, elapsed: Duration) -> f64 { 86 | if self.duration().is_zero() { 87 | 0.0 88 | } else { 89 | (elapsed.clamp(self.start, self.end) - self.start).as_secs_f64() 90 | / self.duration().as_secs_f64() 91 | } 92 | } 93 | 94 | pub fn length(&self) -> f64 { 95 | if let Some(splinemap) = &self.reticulated_spline { 96 | splinemap.length 97 | } else { 98 | self.from.distance_to(self.to) 99 | } 100 | } 101 | 102 | pub fn average_speed(&self) -> f64 { 103 | self.length() / self.duration().as_secs_f64() 104 | } 105 | 106 | #[allow(dead_code)] 107 | pub fn inspect(&self, detail: usize) -> InspectInterval { 108 | let sample_ease = |ease: &BezierEase| { 109 | (0..detail) 110 | .map(|i| { 111 | let t = (i as f64) / (detail as f64); 112 | ( 113 | fixed_bezier(ease.ox, ease.ix, t), 114 | fixed_bezier(ease.oy, ease.iy, t), 115 | ) 116 | }) 117 | .collect() 118 | }; 119 | 120 | InspectInterval { 121 | start: self.start, 122 | end: self.end, 123 | path: self.path(detail, self.end - self.start), 124 | ease: match &self.ease { 125 | Some(Ease::Bezier(bezier)) => sample_ease(bezier), 126 | _ => vec![(0.0, 0.0), (1.0, 1.0)], 127 | }, 128 | reticulated_spline: self 129 | .reticulated_spline 130 | .as_ref() 131 | .map(|reticulated_spline| reticulated_spline.steps.to_vec()), 132 | } 133 | } 134 | } 135 | 136 | impl Animation for Interval { 137 | fn sample(&self, elapsed: Duration) -> V { 138 | // Apply temporal easing (or not) 139 | let percent_elapsed = self.percent_elapsed(elapsed); 140 | let eased_time = self 141 | .ease 142 | .as_ref() 143 | .map(|e| e.ease(percent_elapsed)) 144 | .unwrap_or(percent_elapsed); 145 | 146 | // Map eased distance to spline time using spline map (or not) 147 | let spline_time = self 148 | .reticulated_spline 149 | .as_ref() 150 | .map(|m| spline_ease(&m, eased_time)) 151 | .unwrap_or(eased_time); 152 | 153 | // Look up value along spline (or lerp) 154 | let value = self 155 | .path 156 | .as_ref() 157 | .map(|p| cubic_bezier(&self.from, &p.b1, &p.b2, &self.to, spline_time)) 158 | .unwrap_or_else(|| lerp_components(self.from, self.to, spline_time)); 159 | value 160 | } 161 | } 162 | 163 | impl BoundedAnimation for Interval { 164 | fn duration(&self) -> Duration { 165 | self.end - self.start 166 | } 167 | } 168 | 169 | impl Debug for Interval { 170 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 171 | // This prints the interval in an easy to use copy/pasteable format 172 | write!( 173 | f, 174 | "\n\t\tInterval::new(Duration::from_secs_f64({}f64), Duration::from_secs_f64({}f64), {:?}, {:?}, {:?}, {:?}, {:?}),", 175 | &self.start.as_secs_f64(), 176 | &self.end.as_secs_f64(), 177 | &self.from, 178 | &self.to, 179 | &self.ease, 180 | &self.path, 181 | &self.reticulated_spline 182 | ) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/interval_track.rs: -------------------------------------------------------------------------------- 1 | use gee::en::Num; 2 | 3 | use crate::{ 4 | ease::Ease, 5 | interval::{Frame, Interval}, 6 | spline::{bezier_path::BezierPath, catmull_rom::centripetal_catmull_rom_to_bezier, SplineMap}, 7 | Animatable, Animation, BoundedAnimation, 8 | }; 9 | use core::fmt::Debug; 10 | use std::time::Duration; 11 | 12 | #[derive(Clone)] 13 | pub struct IntervalTrack { 14 | intervals: Vec>, 15 | track_ease: Option, 16 | } 17 | 18 | impl IntervalTrack { 19 | pub fn new() -> Self { 20 | Self { 21 | intervals: vec![], 22 | track_ease: None, 23 | } 24 | } 25 | 26 | pub fn from_interval(interval: Interval) -> Self { 27 | Self::new().with_interval(interval) 28 | } 29 | 30 | pub fn from_intervals(intervals: impl IntoIterator>) -> Self { 31 | Self::new().with_intervals(intervals) 32 | } 33 | 34 | pub fn from_values(duration: Duration, values: Vec, track_ease: Option) -> Self { 35 | match values.len() { 36 | 0 => IntervalTrack::new(), 37 | 1 => IntervalTrack::from_interval(Interval::hold(values[0], Duration::ZERO)), 38 | 2 => IntervalTrack::from_interval(Interval::eased( 39 | Frame::new(Duration::ZERO, values[0]), 40 | Frame::new(duration, values[1]), 41 | track_ease, 42 | )), 43 | _ => { 44 | let lengths = values 45 | .windows(2) 46 | .map(|window| window[0].distance_to(window[1])) 47 | .collect(); 48 | 49 | let durations = 50 | constant_velocity_durations(&accumulate_lengths(&lengths), duration); 51 | 52 | IntervalTrack::from_intervals(values.windows(2).zip(durations.windows(2)).map( 53 | |(value_window, duration_window)| { 54 | Interval::new( 55 | duration_window[0], 56 | duration_window[1], 57 | value_window[0], 58 | value_window[1], 59 | None, 60 | None, 61 | None, 62 | ) 63 | }, 64 | )) 65 | .with_track_ease(track_ease) 66 | } 67 | } 68 | } 69 | 70 | pub fn path( 71 | duration: Duration, 72 | values: Vec, 73 | bookend_style: BookendStyle, 74 | track_ease: Option, 75 | rectify: bool, 76 | ) -> Self { 77 | match values.len() { 78 | 0 => IntervalTrack::new(), 79 | 1 => IntervalTrack::from_interval(Interval::hold(values[0], Duration::ZERO)), 80 | 2 => IntervalTrack::from_interval(Interval::eased( 81 | Frame::new(Duration::ZERO, values[0]), 82 | Frame::new(duration, values[1]), 83 | track_ease, 84 | )), 85 | _ => { 86 | // Add first/last values to refine animation path 87 | let bookended_values = bookend(values, bookend_style); 88 | // Calculate BezierPath and SplineMap for each interval 89 | let (paths, maps) = bookended_values_to_bezier_structs(&bookended_values, rectify); 90 | // Calculate durations for each interval threshold 91 | let lengths = maps.iter().map(|map| map.length).collect::>(); 92 | let durations = 93 | constant_velocity_durations(&accumulate_lengths(&lengths), duration); 94 | 95 | IntervalTrack::from_intervals( 96 | bookended_values 97 | .windows(2) 98 | .skip(1) 99 | .zip(durations.windows(2)) 100 | .zip(paths) 101 | .zip(maps) 102 | .map(|(((value_window, duration_window), path), map)| { 103 | Interval::new( 104 | duration_window[0], 105 | duration_window[1], 106 | value_window[0], 107 | value_window[1], 108 | None, 109 | Some(path), 110 | Some(map), 111 | ) 112 | }), 113 | ) 114 | .with_track_ease(track_ease) 115 | } 116 | } 117 | } 118 | 119 | pub fn auto_bezier(frames: Vec>) -> Self { 120 | let mut acc_elapsed = Duration::ZERO; 121 | 122 | Self::from_intervals(bookend_frames(frames, BookendStyle::Repeat).windows(4).map( 123 | |window| { 124 | let (b0, b1, b2, b3) = centripetal_catmull_rom_to_bezier( 125 | &window[0].value, 126 | &window[1].value, 127 | &window[2].value, 128 | &window[3].value, 129 | ); 130 | acc_elapsed = acc_elapsed + window[2].offset; 131 | Interval::new( 132 | acc_elapsed - window[2].offset, 133 | acc_elapsed, 134 | window[1].value, 135 | window[2].value, 136 | None, 137 | Some(BezierPath::new(b1, b2)), 138 | Some(SplineMap::from_bezier(&b0, &b1, &b2, &b3, true)), 139 | ) 140 | }, 141 | )) 142 | } 143 | 144 | pub fn with_track_ease(mut self, track_ease: Option) -> Self { 145 | self.track_ease = track_ease; 146 | self 147 | } 148 | 149 | pub fn with_interval(mut self, interval: Interval) -> Self { 150 | self.add_interval(interval); 151 | self 152 | } 153 | 154 | pub fn with_intervals(mut self, intervals: impl IntoIterator>) -> Self { 155 | self.add_intervals(intervals); 156 | self 157 | } 158 | 159 | pub fn add_interval(&mut self, interval: Interval) -> &mut Self { 160 | self.intervals.push(interval); 161 | self 162 | } 163 | 164 | pub fn add_intervals(&mut self, intervals: impl IntoIterator>) -> &mut Self { 165 | for interval in intervals { 166 | self.add_interval(interval); 167 | } 168 | self 169 | } 170 | 171 | pub fn current_interval(&self, elapsed: &Duration) -> Option<&Interval> { 172 | self.intervals 173 | .iter() 174 | .find(|interval| interval.end > *elapsed) 175 | .or_else(|| self.intervals.last()) 176 | } 177 | 178 | pub fn length(&self) -> f64 { 179 | self.intervals 180 | .iter() 181 | .fold(0.0, |acc, interval| acc + interval.length()) 182 | } 183 | 184 | // Returns the sampled value at elapsed, as well as the values for any elapsed keyframes 185 | pub fn keyframe_sample(&self, elapsed: Duration) -> Vec { 186 | std::iter::once(self.intervals[0].from) 187 | .chain( 188 | self.intervals 189 | .iter() 190 | .filter(move |interval| { 191 | elapsed > interval.start 192 | && !interval.changes_after(elapsed - interval.start) 193 | }) 194 | .into_iter() 195 | .map(|interval| interval.to), 196 | ) 197 | .chain(std::iter::once( 198 | self.current_interval(&elapsed) 199 | .map(|interval| interval.sample(elapsed)) 200 | .expect("Animation has no current interval!"), 201 | )) 202 | .collect() 203 | } 204 | } 205 | 206 | impl Animation for IntervalTrack { 207 | fn sample(&self, elapsed: Duration) -> V { 208 | let eased_elapsed = self 209 | .track_ease 210 | .as_ref() 211 | .map(|ease| { 212 | self.duration().mul_f64(ease.ease( 213 | (elapsed - self.intervals[0].start).as_secs_f64() 214 | / self.duration().as_secs_f64(), 215 | )) 216 | }) 217 | .unwrap_or(elapsed); 218 | 219 | self.current_interval(&eased_elapsed) 220 | .expect("tried to sample empty `IntervalTrack`") 221 | .sample(eased_elapsed) 222 | } 223 | } 224 | 225 | impl BoundedAnimation for IntervalTrack { 226 | fn duration(&self) -> Duration { 227 | self.intervals 228 | .last() 229 | .map(|last| last.end) 230 | .unwrap_or_default() 231 | } 232 | } 233 | 234 | /// Different ways of selecting additional control points at either end of a series of values. 235 | pub enum BookendStyle { 236 | /// Repeat the first and last values 237 | Repeat, 238 | /// Linearly extrapolate using the first two and last two values 239 | Linear, 240 | /// Use the starting/ending values to form a loop 241 | Loop, 242 | /// Use the first/last three points to calculate a point that would loop back toward the second-to-first/last point 243 | Spiral, 244 | /// Don't add additional bookends, as the user has already included external control points 245 | None, 246 | } 247 | 248 | fn bookend(values: Vec, style: BookendStyle) -> Vec { 249 | if values.is_empty() { 250 | values 251 | } else { 252 | match style { 253 | BookendStyle::Repeat => { 254 | let final_bookend = *values.last().unwrap(); 255 | std::iter::once(values[0]) 256 | .chain(values) 257 | .chain(std::iter::once(final_bookend)) 258 | .collect() 259 | } 260 | BookendStyle::Linear => { 261 | assert!( 262 | values.len() >= 2, 263 | "Linear bookending requires 2 or more values, but you only specified {}", 264 | values.len() 265 | ); 266 | let last_index = values.len() - 1; 267 | let initial_bookend = values[0].add(values[0].sub(values[1])); 268 | let final_bookend = 269 | values[last_index].add(values[last_index].sub(values[last_index - 1])); 270 | 271 | std::iter::once(initial_bookend) 272 | .chain(values) 273 | .chain(std::iter::once(final_bookend)) 274 | .collect() 275 | } 276 | BookendStyle::Loop => { 277 | let initial_bookend = values[values.len() - 2]; 278 | let final_bookend = values[1]; 279 | 280 | std::iter::once(initial_bookend) 281 | .chain(values) 282 | .chain(std::iter::once(final_bookend)) 283 | .collect() 284 | } 285 | BookendStyle::Spiral => { 286 | assert!( 287 | values.len() >= 3, 288 | "Spiral bookending requires 3 or more values, but you only specified {}", 289 | values.len() 290 | ); 291 | let last_index = values.len() - 1; 292 | let initial_bookend = values[0].sub(values[1].sub(values[2])); 293 | let final_bookend = 294 | values[last_index].sub(values[last_index - 1].sub(values[last_index - 2])); 295 | 296 | std::iter::once(initial_bookend) 297 | .chain(values) 298 | .chain(std::iter::once(final_bookend)) 299 | .collect() 300 | } 301 | BookendStyle::None => { 302 | assert!( 303 | values.len() >= 4, 304 | "Catmull-Rom Spline calculation requires four values, but you only specified {}. Perhaps you meant to use a Repeat or Linear BookendStyle?", values.len()); 305 | values 306 | } 307 | } 308 | } 309 | } 310 | 311 | fn bookend_frames(frames: Vec>, style: BookendStyle) -> Vec> { 312 | let bookended_values = bookend(frames.iter().map(|frame| frame.value).collect(), style); 313 | let bookended_durations = std::iter::once(Duration::ZERO) 314 | .chain(frames.into_iter().map(|frame| frame.offset)) 315 | .chain(std::iter::once(Duration::ZERO)) 316 | .collect::>(); 317 | bookended_values 318 | .iter() 319 | .zip(bookended_durations) 320 | .map(|(v, d)| Frame::new(d, *v)) 321 | .collect() 322 | } 323 | 324 | fn bookended_values_to_bezier_structs( 325 | values: &Vec, 326 | rectify: bool, 327 | ) -> (Vec>, Vec) { 328 | values 329 | .windows(4) 330 | .map(|window| { 331 | let (b0, b1, b2, b3) = 332 | centripetal_catmull_rom_to_bezier(&window[0], &window[1], &window[2], &window[3]); 333 | ( 334 | BezierPath::new(b1, b2), 335 | SplineMap::from_bezier(&b0, &b1, &b2, &b3, rectify), 336 | ) 337 | }) 338 | .unzip() 339 | } 340 | 341 | fn accumulate_lengths(lengths: &Vec) -> Vec { 342 | let mut accumulated_lengths = vec![]; 343 | let total_length = lengths.iter().fold(0.0, |total, length| { 344 | accumulated_lengths.push(total); 345 | total + length 346 | }); 347 | accumulated_lengths.push(total_length); 348 | 349 | accumulated_lengths 350 | } 351 | 352 | fn constant_velocity_durations(distances: &Vec, duration: Duration) -> Vec { 353 | distances 354 | .iter() 355 | .map(|distance| duration.mul_f64(distance / distances.last().unwrap())) 356 | .collect() 357 | } 358 | 359 | impl Debug for IntervalTrack { 360 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 361 | write!(f, "IntervalTrack<{}>", std::any::type_name::()) 362 | .expect("Failed to write IntervalTrack type!"); 363 | 364 | for interval in &self.intervals { 365 | write!(f, "\n {:?}", interval).expect("Failed to print Interval information!"); 366 | } 367 | write!(f, "\n\ttrack_ease:\t{:?}", self.track_ease) 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/lerp.rs: -------------------------------------------------------------------------------- 1 | use crate::ComponentWise; 2 | use gee::en::{self, Num as _}; 3 | use std::fmt::Debug; 4 | 5 | /// Linearly interpolates between two numbers. 6 | pub fn lerp_scalar(a: C, b: C, factor: f64) -> C { 7 | // This uses 2 multiplications to be numerically stable! Woo! 8 | (a.to_f64() * (1.0 - factor) + b.to_f64() * factor).cast() 9 | } 10 | 11 | /// Linearly interpolates each component pair in two `ComponentWise`s. 12 | pub fn lerp_components(a: C, b: C, factor: f64) -> C { 13 | a.zip_map(b, |a, b| lerp_scalar(a, b, factor)) 14 | } 15 | 16 | pub fn linear_value(p0: &V, p1: &V, t0: f64, t1: f64, t: f64) -> V { 17 | let d10 = t1 - t0; 18 | let dt0 = t - t0; 19 | let d1t = t1 - t; 20 | 21 | if d10 != 0.0 { 22 | p0.zip_map(*p1, |v0, v1| { 23 | (v0.to_f64() * (d1t / d10) + v1.to_f64() * (dt0 / d10)).cast() 24 | }) 25 | } else { 26 | *p0 27 | } 28 | } 29 | 30 | /// A value that can be animated. 31 | pub trait Animatable: Copy + Debug + ComponentWise { 32 | /// The shortest distance between two `Animatable`s (never negative!) 33 | fn distance_to(self, other: Self) -> f64; 34 | } 35 | 36 | /// A numeric primitive. 37 | /// 38 | /// Implementors of this trait automatically get a [`ComponentWise`] and 39 | /// [`Animatable`] implementation. 40 | pub trait Scalar: en::Num {} 41 | 42 | impl ComponentWise for S { 43 | type Component = Self; 44 | 45 | fn map(self, f: F) -> Self 46 | where 47 | F: Fn(Self::Component) -> Self::Component, 48 | { 49 | f(self) 50 | } 51 | 52 | fn zip_map(self, other: Self, f: F) -> Self 53 | where 54 | F: Fn(Self::Component, Self::Component) -> Self::Component, 55 | { 56 | f(self, other) 57 | } 58 | } 59 | 60 | impl Animatable for S { 61 | fn distance_to(self, other: Self) -> f64 { 62 | (self - other).to_f64().abs() 63 | } 64 | } 65 | 66 | impl Scalar for f32 {} 67 | impl Scalar for f64 {} 68 | impl Scalar for u8 {} 69 | impl Scalar for u16 {} 70 | impl Scalar for u32 {} 71 | impl Scalar for u64 {} 72 | impl Scalar for u128 {} 73 | impl Scalar for usize {} 74 | impl Scalar for i8 {} 75 | impl Scalar for i16 {} 76 | impl Scalar for i32 {} 77 | impl Scalar for i64 {} 78 | impl Scalar for i128 {} 79 | impl Scalar for isize {} 80 | 81 | impl ComponentWise for (C, C) { 82 | type Component = C; 83 | 84 | fn map(self, f: F) -> Self 85 | where 86 | F: Fn(Self::Component) -> Self::Component, 87 | { 88 | (f(self.0), f(self.1)) 89 | } 90 | 91 | fn zip_map(self, other: Self, f: F) -> Self 92 | where 93 | F: Fn(Self::Component, Self::Component) -> Self::Component, 94 | { 95 | (f(self.0, other.0), f(self.1, other.1)) 96 | } 97 | } 98 | 99 | impl Animatable for (C, C) { 100 | fn distance_to(self, other: Self) -> f64 { 101 | let a = self.0 - other.0; 102 | let b = self.1 - other.1; 103 | (a * a + b * b).to_f64().sqrt() 104 | } 105 | } 106 | 107 | impl ComponentWise for gee::Point { 108 | type Component = C; 109 | 110 | fn map(self, f: F) -> Self 111 | where 112 | F: Fn(Self::Component) -> Self::Component, 113 | { 114 | Self::from_tuple(self.to_tuple().map(f)) 115 | } 116 | 117 | fn zip_map(self, other: Self, f: F) -> Self 118 | where 119 | F: Fn(Self::Component, Self::Component) -> Self::Component, 120 | { 121 | Self::from_tuple(self.to_tuple().zip_map(other.to_tuple(), f)) 122 | } 123 | } 124 | 125 | impl Animatable for gee::Point { 126 | fn distance_to(self, other: Self) -> f64 { 127 | (self.to_tuple()).distance_to(other.to_tuple()) 128 | } 129 | } 130 | 131 | impl ComponentWise for gee::Size { 132 | type Component = C; 133 | 134 | fn map(self, f: F) -> Self 135 | where 136 | F: Fn(Self::Component) -> Self::Component, 137 | { 138 | Self::from_tuple(self.to_tuple().map(f)) 139 | } 140 | 141 | fn zip_map(self, other: Self, f: F) -> Self 142 | where 143 | F: Fn(Self::Component, Self::Component) -> Self::Component, 144 | { 145 | Self::from_tuple(self.to_tuple().zip_map(other.to_tuple(), f)) 146 | } 147 | } 148 | 149 | impl Animatable for gee::Size { 150 | fn distance_to(self, other: Self) -> f64 { 151 | (self.to_tuple()).distance_to(other.to_tuple()) 152 | } 153 | } 154 | 155 | impl ComponentWise for gee::Vector { 156 | type Component = C; 157 | 158 | fn map(self, f: F) -> Self 159 | where 160 | F: Fn(Self::Component) -> Self::Component, 161 | { 162 | Self::from_tuple(self.to_tuple().map(f)) 163 | } 164 | 165 | fn zip_map(self, other: Self, f: F) -> Self 166 | where 167 | F: Fn(Self::Component, Self::Component) -> Self::Component, 168 | { 169 | Self::from_tuple(self.to_tuple().zip_map(other.to_tuple(), f)) 170 | } 171 | } 172 | 173 | impl Animatable for gee::Vector { 174 | fn distance_to(self, other: Self) -> f64 { 175 | (self.to_tuple()).distance_to(other.to_tuple()) 176 | } 177 | } 178 | 179 | impl ComponentWise for gee::Angle { 180 | type Component = C; 181 | 182 | fn map(self, f: F) -> Self 183 | where 184 | F: Fn(Self::Component) -> Self::Component, 185 | { 186 | self.map_radians(f) 187 | } 188 | 189 | fn zip_map(self, other: Self, f: F) -> Self 190 | where 191 | F: Fn(Self::Component, Self::Component) -> Self::Component, 192 | { 193 | self.map(|a| f(a, other.radians())) 194 | } 195 | } 196 | 197 | impl Animatable for gee::Angle { 198 | fn distance_to(self, other: Self) -> f64 { 199 | let distance = (other.normalize().radians() - self.normalize().radians()) 200 | .abs() 201 | .to_f64(); 202 | (distance > std::f64::consts::PI) 203 | .then(|| std::f64::consts::TAU - distance) 204 | .unwrap_or(distance) 205 | } 206 | } 207 | 208 | impl ComponentWise for rainbow::LinRgba { 209 | type Component = f64; 210 | 211 | fn map(self, f: F) -> Self 212 | where 213 | F: Fn(Self::Component) -> Self::Component, 214 | { 215 | Self::from_f32_array(rainbow::util::map_all(self.into_f32_array(), |c| { 216 | // TODO: this is concerning 217 | f(c.cast()).to_f32() 218 | })) 219 | } 220 | 221 | fn zip_map(self, other: Self, f: F) -> Self 222 | where 223 | F: Fn(Self::Component, Self::Component) -> Self::Component, 224 | { 225 | let [ar, ag, ab, aa] = self.into_f32_array(); 226 | let [br, bg, bb, ba] = other.into_f32_array(); 227 | Self::from_f32( 228 | // TODO: this is even more concerning 229 | f(ar.cast(), br.cast()).to_f32(), 230 | f(ag.cast(), bg.cast()).to_f32(), 231 | f(ab.cast(), bb.cast()).to_f32(), 232 | f(aa.cast(), ba.cast()).to_f32(), 233 | ) 234 | } 235 | } 236 | 237 | impl Animatable for rainbow::LinRgba { 238 | fn distance_to(self, other: Self) -> f64 { 239 | let [ar, ag, ab, aa] = self.into_f32_array(); 240 | let [br, bg, bb, ba] = other.into_f32_array(); 241 | let r = ar - br; 242 | let g = ag - bg; 243 | let b = ab - bb; 244 | let a = aa - ba; 245 | (r * r + g * g + b * b + a * a).to_f64().sqrt() 246 | } 247 | } 248 | 249 | impl ComponentWise for rainbow::SrgbRgba { 250 | type Component = f64; 251 | 252 | fn map(self, f: F) -> Self 253 | where 254 | F: Fn(Self::Component) -> Self::Component, 255 | { 256 | Self::from_f32_array(rainbow::util::map_all(self.into_f32_array(), |c| { 257 | f(c.cast()).to_f32() 258 | })) 259 | } 260 | 261 | fn zip_map(self, other: Self, f: F) -> Self 262 | where 263 | F: Fn(Self::Component, Self::Component) -> Self::Component, 264 | { 265 | let [ar, ag, ab, aa] = self.into_f32_array(); 266 | let [br, bg, bb, ba] = other.into_f32_array(); 267 | Self::from_f32( 268 | f(ar.cast(), br.cast()).to_f32(), 269 | f(ag.cast(), bg.cast()).to_f32(), 270 | f(ab.cast(), bb.cast()).to_f32(), 271 | f(aa.cast(), ba.cast()).to_f32(), 272 | ) 273 | } 274 | } 275 | 276 | impl Animatable for rainbow::SrgbRgba { 277 | fn distance_to(self, other: Self) -> f64 { 278 | let [ar, ag, ab, aa] = self.into_f32_array(); 279 | let [br, bg, bb, ba] = other.into_f32_array(); 280 | let r = ar - br; 281 | let g = ag - bg; 282 | let b = ab - bb; 283 | let a = aa - ba; 284 | (r * r + g * g + b * b + a * a).to_f64().sqrt() 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Buttery smooth animation toolkit. 2 | 3 | pub mod after_effects; 4 | mod combinators; 5 | mod component_wise; 6 | pub mod constant; 7 | pub mod ease; 8 | pub mod function; 9 | pub mod interval; 10 | pub mod interval_track; 11 | mod lerp; 12 | pub mod spline; 13 | pub mod structured; 14 | 15 | pub use self::{ 16 | combinators::*, component_wise::*, constant::*, ease::*, interval::*, interval_track::*, 17 | lerp::*, spline::*, structured::*, 18 | }; 19 | 20 | use gee::en::Num as _; 21 | pub use paste; 22 | use std::{ 23 | fmt::Debug, 24 | time::{Duration, Instant}, 25 | }; 26 | 27 | /// A value parameterized over time. 28 | /// 29 | /// `Animation` is a semigroup, which makes me sound smart. They can thus be 30 | /// combined using all sorts of cool combinators, while still producing an 31 | /// `Animation` on the other end. 32 | /// 33 | /// Implementors should only implement [`Animation::sample`]. 34 | pub trait Animation: Debug { 35 | /// Samples the animation at the specified duration. 36 | /// 37 | /// `elapsed` is the duration since the "start" of the animation. Animations 38 | /// don't have a fixed start time; they merely start at an `elapsed` of 39 | /// zero, and end at an `elapsed` of infinity. You're thus free to pick any 40 | /// start time you'd like, and the animation is simply given how much time 41 | /// has passed since that start time. 42 | /// 43 | /// # Rules 44 | /// - Sampling at the same `elapsed` multiple times will always return the 45 | /// same value. 46 | /// - Sampling at an `elapsed` smaller than one sampled at previously is 47 | /// valid. 48 | /// - The result is unspecified if `elapsed` is negative. 49 | fn sample(&self, elapsed: Duration) -> V; 50 | 51 | /// Allows you to use combinators on a mutable reference to an animation. 52 | /// 53 | /// This is typically only useful if you're using trait objects. 54 | /// 55 | /// # Warning 56 | /// If `f` panics, then the program will abort. 57 | /// 58 | /// # Examples 59 | /// ``` 60 | /// use celerity::{Animation as _, BoundedAnimation}; 61 | /// 62 | /// struct Game { 63 | /// anim: Box>, 64 | /// } 65 | /// 66 | /// impl Game { 67 | /// fn reverse(&mut self) { 68 | /// self.anim.replace_with(|anim| Box::new(anim.rev())); 69 | /// } 70 | /// } 71 | /// ``` 72 | fn replace_with(&mut self, f: F) 73 | where 74 | Self: Sized, 75 | F: FnOnce(Self) -> Self, 76 | { 77 | replace_with::replace_with_or_abort(self, f) 78 | } 79 | 80 | /// Adapts this animation into a [`BoundedAnimation`] by snipping it at the 81 | /// specified duration. 82 | fn cutoff(self, duration: Duration) -> Cutoff 83 | where 84 | Self: Sized, 85 | { 86 | Cutoff::new(self, duration) 87 | } 88 | 89 | fn interrupt( 90 | self, 91 | other: A, 92 | interrupt_t: Duration, 93 | transition_t: Duration, 94 | ) -> Interrupt, A, V> 95 | where 96 | Self: Sized, 97 | A: Animation, 98 | { 99 | Interrupt::new(self.cutoff(interrupt_t), other, interrupt_t, transition_t) 100 | } 101 | 102 | fn path(&self, sample_count: usize, sample_duration: Duration) -> Vec { 103 | (0..sample_count + 1) 104 | .map(|i| self.sample(sample_duration.mul_f64(i.to_f64() / sample_count.to_f64()))) 105 | .collect() 106 | } 107 | 108 | // Sampling error can occur arround tight curves, showing reduced velocity 109 | fn velocity(&self, sample_count: usize, sample_duration: Duration) -> Vec { 110 | let sample_delta = sample_duration.as_secs_f64() / sample_count.to_f64(); 111 | self.path(sample_count + 1, sample_duration) 112 | .windows(2) 113 | .map(|window| { 114 | window[1].zip_map(window[0], |a, b| (a - b) / V::cast_component(sample_delta)) 115 | }) 116 | .collect() 117 | } 118 | 119 | // Velocity in units/second 120 | fn sample_velocity(&self, elapsed: Duration, delta: f64) -> V { 121 | let inverse_delta = 1.0 / delta; 122 | let a = self.sample(elapsed - Duration::from_secs_f64(delta)); 123 | let b = self.sample(elapsed + Duration::from_secs_f64(delta)); 124 | 125 | b.sub(a).map(|r| r * V::cast_component(inverse_delta)) 126 | } 127 | 128 | // Highly sensitive to sampling errors in velocity 129 | fn acceleration(&self, sample_count: usize, sample_duration: Duration) -> Vec { 130 | self.velocity(sample_count + 1, sample_duration) 131 | .windows(2) 132 | .map(|window| window[1].zip_map(window[0], |a, b| a - b)) 133 | .collect() 134 | } 135 | } 136 | 137 | /// An [`Animation`] where the value stops changing after a known duration. 138 | /// 139 | /// Implementors should only implement [`BoundedAnimation::duration`]. 140 | pub trait BoundedAnimation: Animation { 141 | /// The duration this animation changes over. 142 | /// 143 | /// # Rules (in addition to the rules on [`Animation::sample`]) 144 | /// - Sampling at an `elapsed` greater than this duration will return the 145 | /// same value as when sampling at this duration. 146 | fn duration(&self) -> Duration; 147 | 148 | /// If the animation will still change *after* the given duration. 149 | fn changes_after(&self, elapsed: Duration) -> bool { 150 | elapsed < self.duration() 151 | } 152 | 153 | /// The last time that this animation needs to be sampled at. 154 | fn end(&self, start: Instant) -> Instant { 155 | start + self.duration() 156 | } 157 | 158 | /// The elapsed percentage. This may change if an animation is extended 159 | fn percent_elapsed(&self, elapsed: Duration) -> f64 { 160 | (elapsed.as_secs_f64() / self.duration().as_secs_f64()).clamp(0.0, 1.0) 161 | } 162 | 163 | #[cfg(feature = "d6")] 164 | fn sample_random(&self) -> V { 165 | self.sample(d6::range(Duration::ZERO..=self.duration())) 166 | } 167 | 168 | /// Appends another animation after this animation. 169 | /// 170 | /// If the other animation is also a `BoundedAnimation`, then the resulting 171 | /// animation is a `BoundedAnimation`. 172 | fn chain(self, other: B) -> Chain 173 | where 174 | Self: Sized, 175 | B: Animation, 176 | { 177 | Chain::new(self, other) 178 | } 179 | 180 | /// Cycles this animation forever. 181 | /// 182 | /// The resulting animation is no longer bounded. 183 | fn cycle(self) -> Cycle 184 | where 185 | Self: Sized, 186 | { 187 | Cycle::new(self) 188 | } 189 | 190 | /// Repeats this animation a specified number of times. 191 | fn repeat(self, times: u32) -> Cutoff, V> 192 | where 193 | Self: Sized, 194 | { 195 | let duration = self.duration() * times; 196 | Cutoff::new(Cycle::new(self), duration) 197 | } 198 | 199 | /// Reverses this animation. 200 | fn rev(self) -> Rev 201 | where 202 | Self: Sized, 203 | { 204 | Rev::new(self) 205 | } 206 | 207 | /// Chains this animation with its reverse. 208 | /// 209 | /// Going there and back again has never been easier. 210 | fn mirror(self) -> Chain, V> 211 | where 212 | Self: Clone + Sized, 213 | { 214 | self.clone().chain(self.rev()) 215 | } 216 | } 217 | 218 | // impl Animation for F 219 | // where 220 | // F: Fn(Duration) -> V, 221 | // V: Animatable, 222 | // { 223 | // fn sample(&self, elapsed: Duration) -> V { 224 | // (*self)(elapsed) 225 | // } 226 | // } 227 | 228 | impl Animation for Box 229 | where 230 | A: Animation + ?Sized, 231 | V: Animatable, 232 | { 233 | fn sample(&self, elapsed: Duration) -> V { 234 | A::sample(&*self, elapsed) 235 | } 236 | } 237 | 238 | impl BoundedAnimation for Box 239 | where 240 | A: BoundedAnimation + ?Sized, 241 | V: Animatable, 242 | { 243 | fn duration(&self) -> Duration { 244 | A::duration(&*self) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/spline/bezier.rs: -------------------------------------------------------------------------------- 1 | use crate::Animatable; 2 | 3 | // Newton-Raphson iterations 4 | // Eases which approach a vertical slope at any point will 5 | // require a greater number of iterations. For most reasonable 6 | // curves, 3 iterations is fine, but up to 13 can be required 7 | // in the most extreme examples. It would be cool if this could be 8 | // calculated on a per-ease basis using slope somehow, as even 50 9 | // iterations aren't excessively time consuming, though the returns 10 | // on those computations diminish with less vertical curves. 11 | const NR_ITERATIONS: usize = 13; 12 | 13 | pub fn cubic_bezier_ease(ox: f64, oy: f64, ix: f64, iy: f64, t: f64) -> f64 { 14 | // Uses a cubic 2D bezier curve to map linear interpolation time 15 | // to eased interpolation time. 16 | // 17 | // https://cubic-bezier.com 18 | // 19 | // Bezier control points: 20 | // [0, 0], [ox, oy], [ix, iy], [1, 1] 21 | // 22 | // The easing curve is parametric and 23 | // defined by (s ∈ [0...1]): 24 | // 25 | // x(s) = bezier X 26 | // y(s) = bezier Y 27 | // 28 | // The output is found by inverting x(s) and composing it with y(s): 29 | // out = y(x^-1(t)) 30 | // 31 | // Note that Y is allowed to exceed [0...1] to allow under/overshoot 32 | // of 1D interpolation. But X is always monotonic (= invertible). 33 | // 34 | // Multiple cubic eases are typically chained so that the in 35 | // and out tangents line up at every keyframe. 36 | // 37 | // For 2D path-based animation, this temporal easing is applied 38 | // to each individual spatial spline segment, after rectifying it 39 | // by arc length. These motions never go backwards along the path, 40 | // so both X and Y will be monotonic. 41 | 42 | // Extend the curve beyond start and end by mirroring/flipping 43 | // This allows good central differences to be taken around start/end 44 | 45 | //if ox == ox && ix == iy { return t; } 46 | 47 | if t < 0.0 { 48 | -lookup(ox, oy, ix, iy, -t) 49 | } else if t > 1.0 { 50 | 2.0 - lookup(ox, oy, ix, iy, 2.0 - t) 51 | } else { 52 | lookup(ox, oy, ix, iy, t) 53 | } 54 | } 55 | 56 | fn lookup(ox: f64, oy: f64, ix: f64, iy: f64, t: f64) -> f64 { 57 | fixed_bezier(oy, iy, invert_fixed_bezier(ox, ix, t)) 58 | } 59 | 60 | fn square(x: f64) -> f64 { 61 | x * x 62 | } 63 | fn cube(x: f64) -> f64 { 64 | x * x * x 65 | } 66 | 67 | // Exact x(t) with fixed first and last control point 68 | pub fn fixed_bezier(ox: f64, ix: f64, t: f64) -> f64 { 69 | let it = 1.0 - t; 70 | ox * 3.0 * square(it) * t + ix * 3.0 * it * square(t) + cube(t) 71 | } 72 | 73 | // Exact dx(t)/dt with fixed first and last control point 74 | pub fn dt_fixed_bezier(ox: f64, ix: f64, t: f64) -> f64 { 75 | 3.0 * (ox * (1.0 - t * (4.0 - 3.0 * t)) + t * (ix * (2.0 - 3.0 * t) + t)) 76 | } 77 | 78 | // Approximate t = x^-1(x) 79 | pub fn invert_fixed_bezier(ox: f64, ix: f64, x: f64) -> f64 { 80 | // Use Newton-Raphson iteration starting from the input time 81 | // 82 | // Converges O(1/n^2) almost everywhere, 83 | // except near horizontal tangents where it is O(1/n). 84 | let mut t = x; 85 | for _ in 1..=NR_ITERATIONS { 86 | let v = fixed_bezier(ox, ix, t) - x; 87 | let dvdt = dt_fixed_bezier(ox, ix, t); 88 | 89 | if v == 0.0 { 90 | break; 91 | } 92 | if dvdt == 0.0 { 93 | break; 94 | } 95 | 96 | t = t - v / dvdt; 97 | } 98 | 99 | t 100 | } 101 | 102 | // Find position for points with arbitrary # of dimensions 103 | pub fn cubic_bezier(b0: &V, b1: &V, b2: &V, b3: &V, t: f64) -> V { 104 | let it = 1.0 - t; 105 | let t0 = b0.map(|v0| V::cast_component(cube(it)) * v0); 106 | let t1 = b1.map(|v1| V::cast_component(3.0 * square(it) * t) * v1); 107 | let t2 = b2.map(|v2| V::cast_component(3.0 * it * square(t)) * v2); 108 | let t3 = b3.map(|v3| V::cast_component(cube(t)) * v3); 109 | 110 | let result = t0 111 | .zip_map(t1, |v, v1| v + v1) 112 | .zip_map(t2, |v, v2| v + v2) 113 | .zip_map(t3, |v, v3| v + v3); 114 | 115 | result 116 | } 117 | 118 | // Find (exact) tangent/velocity for points with arbitrary # of dimensions 119 | pub fn dt_cubic_bezier(b0: &V, b1: &V, b2: &V, b3: &V, t: f64) -> V { 120 | let it = 1.0 - t; 121 | let t0 = b0.map(|v0| V::cast_component(-3.0 * square(it)) * v0); 122 | let t1 = b1.map(|v1| V::cast_component(3.0 * it * (it - 2.0 * t)) * v1); 123 | let t2 = b2.map(|v2| V::cast_component(3.0 * t * (2.0 * it - t)) * v2); 124 | let t3 = b3.map(|v3| V::cast_component(3.0 * square(t)) * v3); 125 | 126 | let result = t0 127 | .zip_map(t1, |v, v1| v + v1) 128 | .zip_map(t2, |v, v2| v + v2) 129 | .zip_map(t3, |v, v3| v + v3); 130 | 131 | result 132 | } 133 | 134 | #[cfg(test)] 135 | mod tests { 136 | use super::*; 137 | 138 | // Tolerance for testing numerical accuracy 139 | const TEST_TOLERANCE_EXACT: f64 = 1e-7; 140 | const TEST_TOLERANCE_APPROX: f64 = 1e-3; 141 | 142 | // Step size for epsilon/delta slope test 143 | const TEST_SLOPE_DELTA: f64 = 1e-6; 144 | const TEST_SLOPE_EPSILON: f64 = 1e-4; 145 | 146 | // Number of steps along curve to test 147 | const TEST_STEPS: usize = 1000; 148 | 149 | pub fn approx_eq(lhs: f64, rhs: f64, epsilon: f64) -> bool { 150 | lhs.is_finite() && rhs.is_finite() && ((lhs - epsilon)..(lhs + epsilon)).contains(&rhs) 151 | } 152 | 153 | #[test] 154 | fn test() { 155 | // Three different parametrizations of a diagonal 156 | test_curve(0.166, 0.166, 0.833, 0.833); 157 | test_curve(0.333, 0.333, 0.666, 0.666); 158 | test_curve(0.1, 0.1, 0.666, 0.666); 159 | 160 | // Slight curve 161 | test_curve(0.125, 0.166, 0.833, 0.833); 162 | 163 | // Ease-out 164 | test_curve(0.166, 0.0, 0.833, 0.833); 165 | 166 | // Ease-in 167 | test_curve(0.166, 0.166, 0.833, 1.0); 168 | 169 | // Ease-in-out 170 | test_curve(0.125, 0.0, 0.833, 1.0); 171 | 172 | // Strong ease-in-out 173 | test_curve(0.333, 0.0, 0.666, 1.0); 174 | 175 | // Overshoot/undershoot 176 | test_curve(0.166, -0.25, 0.833, 1.25); 177 | } 178 | 179 | fn test_curve(ox: f64, oy: f64, ix: f64, iy: f64) { 180 | test_invert(ox, ix); 181 | test_smooth(ox, oy, ix, iy); 182 | test_slope(ox, oy, ix, iy); 183 | } 184 | 185 | // Test if x(x^-1(t)) is within tolerance everywhere 186 | fn test_invert(ox: f64, ix: f64) { 187 | let step: f64 = 1.0 / (TEST_STEPS as f64); 188 | for i in 0..=TEST_STEPS { 189 | let ti = (i as f64) * step; 190 | 191 | let to = invert_fixed_bezier(ox, ix, ti); 192 | let tti = fixed_bezier(ox, ix, to); 193 | 194 | assert!( 195 | approx_eq(tti, ti, TEST_TOLERANCE_EXACT), 196 | "identity failed on 1D cubic bezier {} {} at {} != {}", 197 | ox, 198 | ix, 199 | ti, 200 | tti 201 | ) 202 | } 203 | } 204 | 205 | // Test curve smoothness 206 | // Check if each point is near the average of its neighbors 207 | fn test_smooth(ox: f64, oy: f64, ix: f64, iy: f64) { 208 | let step: f64 = 1.0 / (TEST_STEPS as f64); 209 | 210 | for i in 0..=TEST_STEPS { 211 | let ti = (i as f64) * step; 212 | 213 | let to1 = cubic_bezier_ease(ox, oy, ix, iy, ti - step); 214 | let to2 = cubic_bezier_ease(ox, oy, ix, iy, ti); 215 | let to3 = cubic_bezier_ease(ox, oy, ix, iy, ti + step); 216 | 217 | let mid = (to1 + to3) / 2.0; 218 | 219 | assert!( 220 | approx_eq(mid, to2, TEST_TOLERANCE_APPROX), 221 | "curve is not smooth at {}: {} {} {}", 222 | ti, 223 | to1, 224 | to2, 225 | to3 226 | ); 227 | } 228 | } 229 | 230 | // Check if start/end slopes match the 2D bezier tangents 231 | fn test_slope(ox: f64, oy: f64, ix: f64, iy: f64) { 232 | // Use central difference around start and end 233 | let t_out2 = cubic_bezier_ease(ox, oy, ix, iy, TEST_SLOPE_DELTA); 234 | let t_out1 = cubic_bezier_ease(ox, oy, ix, iy, -TEST_SLOPE_DELTA); 235 | 236 | let t_in2 = cubic_bezier_ease(ox, oy, ix, iy, 1.0 + TEST_SLOPE_DELTA); 237 | let t_in1 = cubic_bezier_ease(ox, oy, ix, iy, 1.0 - TEST_SLOPE_DELTA); 238 | 239 | // Get slopes 240 | let slope_out = (t_out2 - t_out1) / (2.0 * TEST_SLOPE_DELTA); 241 | let slope_in = (t_in2 - t_in1) / (2.0 * TEST_SLOPE_DELTA); 242 | 243 | assert!( 244 | approx_eq(slope_out, oy / ox, TEST_SLOPE_EPSILON), 245 | "out slope doesn't match {} {}: {} != {}", 246 | ox, 247 | oy, 248 | oy / ox, 249 | slope_out, 250 | ); 251 | 252 | assert!( 253 | approx_eq(slope_in, (1.0 - iy) / (1.0 - ix), TEST_SLOPE_EPSILON), 254 | "in slope doesn't match {} {}: {} != {}", 255 | ix, 256 | iy, 257 | (1.0 - iy) / (1.0 - ix), 258 | slope_in, 259 | ); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/spline/bezier_ease.rs: -------------------------------------------------------------------------------- 1 | use super::bezier::cubic_bezier_ease; 2 | use crate::ease::Ease; 3 | use gee::Point; 4 | 5 | // Describes the temporal Bezier ease between two Animatables 6 | // as a relative curve from (0, 0) to (1, 1). 7 | // 8 | // X values always range [0...1] 9 | // Y values usually range [0...1] 10 | #[derive(Clone, Copy, Debug)] 11 | pub struct BezierEase { 12 | pub ox: f64, 13 | pub oy: f64, 14 | pub ix: f64, 15 | pub iy: f64, 16 | } 17 | 18 | impl BezierEase { 19 | pub const fn new(ox: f64, oy: f64, ix: f64, iy: f64) -> Self { 20 | Self { ox, oy, ix, iy } 21 | } 22 | 23 | pub const fn new_ease(ox: f64, oy: f64, ix: f64, iy: f64) -> Ease { 24 | Ease::Bezier(Self::new(ox, oy, ix, iy)) 25 | } 26 | 27 | pub fn as_points(&self) -> (Point, Point, Point, Point) { 28 | let b0 = Point::new(0.0, 0.0); 29 | let b1 = Point::new(self.ox, self.oy); 30 | let b2 = Point::new(self.ix, self.iy); 31 | let b3 = Point::new(1.0, 1.0); 32 | 33 | (b0, b1, b2, b3) 34 | } 35 | 36 | pub const fn linear() -> Ease { 37 | Self::new_ease(0.16, 0.16, 0.84, 0.84) 38 | } 39 | pub const fn ease_in() -> Ease { 40 | Self::new_ease(0.16, 0.0, 0.84, 0.84) 41 | } 42 | pub const fn ease_out() -> Ease { 43 | Self::new_ease(0.16, 0.16, 0.84, 1.0) 44 | } 45 | pub const fn ease_in_out() -> Ease { 46 | Self::new_ease(0.16, 0.0, 0.84, 1.0) 47 | } 48 | 49 | pub fn ease(&self, t: f64) -> f64 { 50 | cubic_bezier_ease(self.ox, self.oy, self.ix, self.iy, t.clamp(0.0, 1.0)) 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use super::*; 57 | use crate::{ 58 | interval::Interval, interval_track::IntervalTrack, lerp_components, lerp_scalar, 59 | spline::bezier_path::BezierPath, spline::SplineMap, Animatable, Animation as _, 60 | }; 61 | use gee::en::Num as _; 62 | use std::time::Duration; 63 | 64 | const TOLERANCE: f64 = 1e-4; 65 | const TOLERANCE_LOOSE: f64 = 1e-3; 66 | 67 | pub fn approx_eq(lhs: f64, rhs: f64, epsilon: f64) -> bool { 68 | let eq = 69 | lhs.is_finite() && rhs.is_finite() && ((lhs - epsilon)..(lhs + epsilon)).contains(&rhs); 70 | if !eq { 71 | println!("{} != {}", lhs, rhs); 72 | } 73 | eq 74 | } 75 | 76 | pub fn approx_eq_point(lhs: gee::Point, rhs: gee::Point, epsilon: f64) -> bool { 77 | approx_eq(lhs.x, rhs.x, epsilon) && approx_eq(lhs.y, rhs.y, epsilon) 78 | } 79 | 80 | #[test] 81 | fn test_scalar_linear() { 82 | let start = Duration::from_secs_f64(10.0); 83 | let q1 = Duration::from_secs_f64(12.5); 84 | let mid = Duration::from_secs_f64(15.0); 85 | let q2 = Duration::from_secs_f64(17.5); 86 | let end = Duration::from_secs_f64(20.0); 87 | 88 | let from: f64 = 1.0; 89 | let to: f64 = 3.0; 90 | 91 | let interval = Interval { 92 | start, 93 | end, 94 | from, 95 | to, 96 | ease: None, 97 | path: None, 98 | reticulated_spline: None, 99 | }; 100 | 101 | let track = IntervalTrack::new().with_interval(interval); 102 | 103 | // Animation should be linear 104 | assert!(approx_eq(track.sample(start), from, TOLERANCE)); 105 | assert!(approx_eq(track.sample(end), to, TOLERANCE)); 106 | assert!(approx_eq( 107 | track.sample(mid), 108 | lerp_scalar(from, to, 0.5), 109 | TOLERANCE, 110 | )); 111 | assert!(approx_eq( 112 | track.sample(q1), 113 | lerp_scalar(from, to, 0.25), 114 | TOLERANCE, 115 | )); 116 | assert!(approx_eq( 117 | track.sample(q2), 118 | lerp_scalar(from, to, 0.75), 119 | TOLERANCE, 120 | )); 121 | } 122 | 123 | #[test] 124 | fn test_scalar_eased() { 125 | let start = Duration::from_secs_f64(10.0); 126 | let q1 = Duration::from_secs_f64(12.5); 127 | let mid = Duration::from_secs_f64(15.0); 128 | let q2 = Duration::from_secs_f64(17.5); 129 | let end = Duration::from_secs_f64(20.0); 130 | 131 | let from: f64 = 1.0; 132 | let to: f64 = 3.0; 133 | 134 | let interval = Interval { 135 | start, 136 | end, 137 | from, 138 | to, 139 | ease: Some(BezierEase::new_ease(0.5, 0.0, 0.5, 1.0)), 140 | path: None, 141 | reticulated_spline: None, 142 | }; 143 | 144 | let track = IntervalTrack::new().with_interval(interval); 145 | 146 | // Animation should ease towards start and end 147 | assert!(approx_eq(track.sample(start), from, TOLERANCE)); 148 | assert!(approx_eq(track.sample(end), to, TOLERANCE)); 149 | assert!(approx_eq( 150 | track.sample(mid), 151 | lerp_scalar(from, to, 0.5), 152 | TOLERANCE, 153 | )); 154 | assert!(approx_eq( 155 | track.sample(q1), 156 | lerp_scalar(from, to, 0.1059), 157 | TOLERANCE, 158 | )); 159 | assert!(approx_eq( 160 | track.sample(q2), 161 | lerp_scalar(from, to, 0.8941), 162 | TOLERANCE, 163 | )); 164 | } 165 | 166 | #[test] 167 | fn test_vector_eased() { 168 | let start = Duration::from_secs_f64(10.0); 169 | let q1 = Duration::from_secs_f64(12.5); 170 | let mid = Duration::from_secs_f64(15.0); 171 | let q2 = Duration::from_secs_f64(17.5); 172 | let end = Duration::from_secs_f64(20.0); 173 | 174 | let from: gee::Point = gee::Point::new(0.0, 0.0); 175 | let to: gee::Point = gee::Point::new(3.0, 4.0); 176 | 177 | let interval = Interval { 178 | start, 179 | end, 180 | from, 181 | to, 182 | ease: Some(BezierEase::new_ease(0.5, 0.0, 0.5, 1.0)), 183 | path: None, 184 | reticulated_spline: None, 185 | }; 186 | 187 | let track = IntervalTrack::new().with_interval(interval); 188 | 189 | // Animation should ease towards start and end 190 | assert!(approx_eq_point(track.sample(start), from, TOLERANCE)); 191 | assert!(approx_eq_point(track.sample(end), to, TOLERANCE)); 192 | assert!(approx_eq_point( 193 | track.sample(mid), 194 | lerp_components(from, to, 0.5), 195 | TOLERANCE, 196 | )); 197 | assert!(approx_eq_point( 198 | track.sample(q1), 199 | lerp_components(from, to, 0.1059), 200 | TOLERANCE, 201 | )); 202 | assert!(approx_eq_point( 203 | track.sample(q2), 204 | lerp_components(from, to, 0.8941), 205 | TOLERANCE, 206 | )); 207 | } 208 | 209 | #[test] 210 | fn test_vector_linear_path() { 211 | let start = Duration::from_secs_f64(10.0); 212 | let end = Duration::from_secs_f64(20.0); 213 | 214 | let from: gee::Point = gee::Point::new(-4.0, 0.0); 215 | let to: gee::Point = gee::Point::new(4.0, 0.0); 216 | 217 | let b1: gee::Point = gee::Point::new(-4.0, -4.0); 218 | let b2: gee::Point = gee::Point::new(4.0, 4.0); 219 | 220 | let spline_map = SplineMap::from_bezier(&from, &b1, &b2, &to, true); 221 | 222 | let length = spline_map.length; 223 | println!("length {}", length); 224 | 225 | let interval = Interval { 226 | start, 227 | end, 228 | from, 229 | to, 230 | ease: None, 231 | path: Some(BezierPath { b1, b2 }), 232 | reticulated_spline: Some(spline_map), 233 | }; 234 | 235 | let track = IntervalTrack::new().with_interval(interval); 236 | 237 | assert!(approx_eq_point(track.sample(start), from, TOLERANCE)); 238 | assert!(approx_eq_point(track.sample(end), to, TOLERANCE)); 239 | 240 | // Animation should be linear by arc length 241 | let steps: usize = 100; 242 | let step = length / steps.to_f64(); 243 | for i in 0..steps { 244 | let t1 = i.to_f64() / steps.to_f64(); 245 | let t2 = (i + 1).to_f64() / steps.to_f64(); 246 | let p1 = track.sample(start + (end - start).mul_f64(t1)); 247 | let p2 = track.sample(start + (end - start).mul_f64(t2)); 248 | println!("{} {} @ {}", p1.distance_to(p2), step, t1); 249 | assert!( 250 | approx_eq(p1.distance_to(p2), step, TOLERANCE_LOOSE), 251 | "unequal step at {}", 252 | t1 253 | ); 254 | } 255 | } 256 | 257 | #[test] 258 | fn test_vector_eased_path() { 259 | let start = Duration::from_secs_f64(10.0); 260 | let end = Duration::from_secs_f64(20.0); 261 | 262 | let from: gee::Point = gee::Point::new(-4.0, 0.0); 263 | let to: gee::Point = gee::Point::new(4.0, 0.0); 264 | 265 | let b1: gee::Point = gee::Point::new(-4.0, -4.0); 266 | let b2: gee::Point = gee::Point::new(4.0, 4.0); 267 | 268 | let spline_map = SplineMap::from_bezier(&from, &b1, &b2, &to, true); 269 | 270 | let length = spline_map.length; 271 | println!("length {}", length); 272 | 273 | let interval = Interval { 274 | start, 275 | end, 276 | from, 277 | to, 278 | ease: Some(BezierEase::new_ease(0.5, 0.0, 0.5, 1.0)), 279 | path: Some(BezierPath { b1, b2 }), 280 | reticulated_spline: Some(spline_map), 281 | }; 282 | 283 | let track = IntervalTrack::new().with_interval(interval); 284 | 285 | assert!(approx_eq_point(track.sample(start), from, TOLERANCE)); 286 | assert!(approx_eq_point(track.sample(end), to, TOLERANCE)); 287 | 288 | // Animation should be eased perfectly symmetricly, on a curved path 289 | let steps: usize = 100; 290 | let step = length / (steps as f64); 291 | for i in 0..steps { 292 | let t1 = i.to_f64() / steps.to_f64(); 293 | let t2 = (i + 1).to_f64() / steps.to_f64(); 294 | 295 | let t3 = 1.0 - i.to_f64() / steps.to_f64(); 296 | let t4 = 1.0 - (i + 1).to_f64() / steps.to_f64(); 297 | 298 | let p1 = track.sample(start + (end - start).mul_f64(t1)); 299 | let p2 = track.sample(start + (end - start).mul_f64(t2)); 300 | 301 | let p3 = track.sample(start + (end - start).mul_f64(t3)); 302 | let p4 = track.sample(start + (end - start).mul_f64(t4)); 303 | 304 | println!("{} {} @ {}", p1.distance_to(p2), step, t1); 305 | println!("{} {} @ {}", p3.distance_to(p4), step, t4); 306 | assert!( 307 | approx_eq(p1.distance_to(p2), p3.distance_to(p4), TOLERANCE_LOOSE), 308 | "unequal step at {}/{}", 309 | t1, 310 | t4 311 | ); 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/spline/bezier_path.rs: -------------------------------------------------------------------------------- 1 | use super::bezier::cubic_bezier; 2 | use crate::Animatable; 3 | 4 | // Describes the two middle control points for a bezier path 5 | // between an interval's spatial endpoints. 6 | // 7 | // These are in absolute coordinates, 8 | // i.e. (from, b1, b2, to) is a bezier. 9 | #[derive(Clone, Debug)] 10 | pub struct BezierPath { 11 | pub b1: V, 12 | pub b2: V, 13 | } 14 | 15 | impl BezierPath { 16 | pub fn new(b1: V, b2: V) -> Self { 17 | Self { b1, b2 } 18 | } 19 | 20 | pub fn position(&self, b0: &V, b3: &V, t: f64) -> V { 21 | cubic_bezier(b0, &self.b1, &self.b2, b3, t) 22 | } 23 | } 24 | 25 | pub struct BezierCurve { 26 | pub b0: V, 27 | pub b1: V, 28 | pub b2: V, 29 | pub b3: V, 30 | } 31 | 32 | impl BezierCurve { 33 | pub fn new(b0: V, b1: V, b2: V, b3: V) -> Self { 34 | Self { b0, b1, b2, b3 } 35 | } 36 | 37 | pub fn position(&self, t: f64) -> V { 38 | cubic_bezier(&self.b0, &self.b1, &self.b2, &self.b3, t) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/spline/catmull_rom.rs: -------------------------------------------------------------------------------- 1 | use crate::Animatable; 2 | use gee::en::Num as _; 3 | 4 | // const UNIFORM_ALPHA: f64 = 0.0; 5 | const CENTRIPETAL_ALPHA: f64 = 0.5; 6 | // const CHORDAL_ALPHA: f64 = 1.0; 7 | 8 | const TANGENT_EPSILON: f64 = 1e-5; 9 | 10 | pub fn catmull_rom_value( 11 | p0: &V, 12 | p1: &V, 13 | p2: &V, 14 | p3: &V, 15 | t0: f64, 16 | t1: f64, 17 | t2: f64, 18 | t3: f64, 19 | t: f64, 20 | ) -> V { 21 | // In a Catmull-Rom (CR) spline, four control points are used along with four 22 | // knots describing the arc lengths between the points. For a centripetal CR 23 | // spline, the knots (t0-3) are described as follows: 24 | // 25 | // t0 = 0 26 | // ti+1 = distance(pi, pi+1)^alpha + ti 27 | // 28 | // The t values may also be arbitrarily spaced (t0 is always 0), and the spline 29 | // will still be continuous, though the lengths of the arc and the tangents 30 | // at the control points will be affected. 31 | // 32 | // From our t values, we calculate a cubic function describing the arc between 33 | // points p1 and p2 34 | // 35 | // C = (t2 - t / t2 - t1) * B1 + (t - t1 / t2 - t1) * B2 36 | // B1 = (t2 - t / t2 - t0) * A1 + (t - t0 / t2 - t0) * A2 37 | // B2 = (t3 - t / t3 - t1) * A2 + (t - t1 / t3 - t1) * A3 38 | // A1 = (t1 - t / t1 - t0) * P0 + (t - t0 / t1 - t0) * P1 39 | // A2 = (t2 - t / t2 - t1) * P1 + (t - t1 / t2 - t1) * P2 40 | // A3 = (t3 - t / t3 - t2) * P3 + (t - t2 / t3 - t2) * P4 41 | // 42 | // This cubic function gives the output of the spline for values of t ranging 43 | // between t1 and t2. 44 | 45 | // The _D_ifference between t_#_ and t_#_ 46 | let d10 = t1 - t0; 47 | let d1t = t1 - t; 48 | let d20 = t2 - t0; 49 | let d21 = t2 - t1; 50 | let d2t = t2 - t; 51 | let d31 = t3 - t1; 52 | let d32 = t3 - t2; 53 | let d3t = t3 - t; 54 | let dt0 = t - t0; 55 | 56 | let a1 = if d10 != 0.0 { 57 | p0.zip_map(*p1, |v0, v1| { 58 | (v0.to_f64() * (d1t / d10) + v1.to_f64() * (dt0 / d10)).cast() 59 | }) 60 | } else { 61 | *p0 62 | }; 63 | let a2 = if d21 != 0.0 { 64 | p1.zip_map(*p2, |v1, v2| { 65 | (v1.to_f64() * (d2t / d21) + v2.to_f64() * (-d1t / d21)).cast() 66 | }) 67 | } else { 68 | *p1 69 | }; 70 | let a3 = if d32 != 0.0 { 71 | p2.zip_map(*p3, |v2, v3| { 72 | (v2.to_f64() * (d3t / d32) + v3.to_f64() * (-d2t / d32)).cast() 73 | }) 74 | } else { 75 | *p2 76 | }; 77 | 78 | let b1 = if d20 != 0.0 { 79 | a1.zip_map(a2, |v1, v2| { 80 | (v1.to_f64() * (d2t / d20) + v2.to_f64() * (dt0 / d20)).cast() 81 | }) 82 | } else { 83 | a1 84 | }; 85 | let b2 = if d31 != 0.0 { 86 | a2.zip_map(a3, |v2, v3| { 87 | (v2.to_f64() * (d3t / d31) + v3.to_f64() * (-d1t / d31)).cast() 88 | }) 89 | } else { 90 | a2 91 | }; 92 | 93 | if d21 != 0.0 { 94 | b1.zip_map(b2, |v1, v2| { 95 | (v1.to_f64() * (d2t / d21) + v2.to_f64() * (-d1t / d21)).cast() 96 | }) 97 | } else { 98 | b1 99 | } 100 | } 101 | 102 | // Convert non-uniform catmull rom to equivalent bezier spline 103 | // 104 | // Uses numerical approximation 105 | pub fn catmull_rom_to_bezier( 106 | p0: &V, 107 | p1: &V, 108 | p2: &V, 109 | p3: &V, 110 | t0: f64, 111 | t1: f64, 112 | t2: f64, 113 | t3: f64, 114 | ) -> (V, V, V, V) { 115 | // Inner knot distance 116 | let s = t2 - t1; 117 | 118 | // Sample central difference around start and end 119 | let a1 = catmull_rom_value(p0, p1, p2, p3, t0, t1, t2, t3, t1 - s * TANGENT_EPSILON); 120 | let b1 = catmull_rom_value(p0, p1, p2, p3, t0, t1, t2, t3, t1 + s * TANGENT_EPSILON); 121 | 122 | let a2 = catmull_rom_value(p0, p1, p2, p3, t0, t1, t2, t3, t2 - s * TANGENT_EPSILON); 123 | let b2 = catmull_rom_value(p0, p1, p2, p3, t0, t1, t2, t3, t2 + s * TANGENT_EPSILON); 124 | 125 | // Scale to appropriate range 126 | // Bezier has factor of 3, central difference has factor of 2 127 | let d1 = b1 128 | .zip_map(a1, |b, a| b - a) 129 | .map(|d| d * V::cast_component(1.0 / (TANGENT_EPSILON * 6.0))); 130 | let d2 = a2 131 | .zip_map(b2, |b, a| b - a) 132 | .map(|d| d * V::cast_component(1.0 / (TANGENT_EPSILON * 6.0))); 133 | 134 | ( 135 | *p1, 136 | p1.zip_map(d1, |val, d| val + d), 137 | p2.zip_map(d2, |val, d| val + d), 138 | *p2, 139 | ) 140 | } 141 | 142 | pub fn centripetal_catmull_rom_to_bezier( 143 | p0: &V, 144 | p1: &V, 145 | p2: &V, 146 | p3: &V, 147 | ) -> (V, V, V, V) { 148 | let (t0, t1, t2, t3) = t_values(p0, p1, p2, p3, CENTRIPETAL_ALPHA); 149 | catmull_rom_to_bezier(p0, p1, p2, p3, t0, t1, t2, t3) 150 | } 151 | 152 | // Calculate values of T for a given alpha 153 | // alpha = 0.0: Uniform spline 154 | // alpha = 0.5: Centripetal spline 155 | // alpha = 1.0: Chordal spline 156 | pub fn t_values(p0: &V, p1: &V, p2: &V, p3: &V, alpha: f64) -> (f64, f64, f64, f64) { 157 | let t1 = f64::powf(p0.distance_to(*p1), alpha); 158 | let t2 = f64::powf(p1.distance_to(*p2), alpha) + t1; 159 | let t3 = f64::powf(p2.distance_to(*p3), alpha) + t2; 160 | 161 | (0.0, t1, t2, t3) 162 | } 163 | 164 | pub fn centripetal_catmull_rom(p0: &V, p1: &V, p2: &V, p3: &V, t: f64) -> V { 165 | let (t0, t1, t2, t3) = t_values(p0, p1, p2, p3, CENTRIPETAL_ALPHA); 166 | 167 | // Our input t value ranges from 0-1, and needs to be scaled to match the spline's knots 168 | let adjusted_t = t1 + ((t2 - t1) * t); 169 | catmull_rom_value(&p0, &p1, &p2, &p3, t0, t1, t2, t3, adjusted_t) 170 | } 171 | 172 | #[cfg(test)] 173 | mod tests { 174 | use super::*; 175 | use crate::spline::bezier::cubic_bezier; 176 | 177 | // Accuracy threshold for matching curves 178 | const TEST_EPSILON: f64 = 1e-7; 179 | 180 | // Steps to divide curve into 181 | const TEST_STEPS: usize = 1000; 182 | 183 | // 184 | #[test] 185 | fn test_match_cr_bez() { 186 | let p1 = (0.0, 0.0); 187 | let p2 = (1.0, 0.0); 188 | let p3 = (2.0, 2.1); 189 | let p4 = (-1.0, 4.0); 190 | 191 | let t1: f64 = -0.04; 192 | let t2: f64 = 0.15; 193 | let t3: f64 = 0.2; 194 | let t4: f64 = 0.3; 195 | 196 | let bezier = catmull_rom_to_bezier(&p1, &p2, &p3, &p4, t1, t2, t3, t4); 197 | let b1 = bezier.0; 198 | let b2 = bezier.1; 199 | let b3 = bezier.2; 200 | let b4 = bezier.3; 201 | 202 | for i in 0..=TEST_STEPS { 203 | let d = (i as f64) / (TEST_STEPS as f64); 204 | let cr = catmull_rom_value(&p1, &p2, &p3, &p4, t1, t2, t3, t4, t2 + (t3 - t2) * d); 205 | let bz = cubic_bezier(&b1, &b2, &b3, &b4, d); 206 | 207 | assert!( 208 | Animatable::distance_to(cr, bz) < TEST_EPSILON, 209 | "bezier does not match catmull rom at {}: {},{} != {},{}", 210 | d, 211 | cr.0, 212 | cr.1, 213 | bz.0, 214 | bz.1 215 | ); 216 | } 217 | } 218 | 219 | #[test] 220 | fn test_degen_knots() { 221 | let p1 = (0.0, 0.0); 222 | let p2 = (0.0, 0.0); 223 | let p3 = (2.0, 0.0); 224 | let p4 = (-1.0, 4.0); 225 | 226 | let t1: f64 = -0.1; 227 | let t2: f64 = -0.1; 228 | let t3: f64 = 0.2; 229 | let t4: f64 = 0.3; 230 | 231 | let bezier = catmull_rom_to_bezier(&p1, &p2, &p3, &p4, t1, t2, t3, t4); 232 | let b1 = bezier.0; 233 | let b2 = bezier.1; 234 | let b3 = bezier.2; 235 | let b4 = bezier.3; 236 | 237 | for i in 0..=TEST_STEPS { 238 | let d = (i as f64) / (TEST_STEPS as f64); 239 | let cr = catmull_rom_value(&p1, &p2, &p3, &p4, t1, t2, t3, t4, t2 + (t3 - t2) * d); 240 | let bz = cubic_bezier(&b1, &b2, &b3, &b4, d); 241 | 242 | assert!( 243 | Animatable::distance_to(cr, bz) < TEST_EPSILON, 244 | "bezier does not match catmull rom at {}: {},{} != {},{}", 245 | d, 246 | cr.0, 247 | cr.1, 248 | bz.0, 249 | bz.1 250 | ); 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/spline/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod bezier; 2 | pub mod bezier_ease; 3 | pub mod bezier_path; 4 | pub mod catmull_rom; 5 | 6 | use self::{bezier::dt_cubic_bezier, catmull_rom::catmull_rom_value}; 7 | pub use self::{bezier_ease::*, bezier_path::*}; 8 | use crate::{lerp::linear_value, Animatable}; 9 | use gee::en::num_traits::Zero as _; 10 | 11 | // Spline polyline subdivision 12 | const SPLINE_SUBDIVISION: usize = 64; 13 | 14 | // Map from time to distance 15 | #[derive(Clone, Debug)] 16 | pub struct SplineMap { 17 | // Animatable always lerps using f64, and distance is always an f64 18 | pub steps: Vec<(f64, f64)>, 19 | pub length: f64, 20 | rectify: bool, 21 | } 22 | 23 | // Look up linear easing by arc length using a spline map 24 | 25 | pub fn spline_ease(spline_map: &SplineMap, t: f64) -> f64 { 26 | spline_map 27 | .rectify 28 | .then(|| { 29 | // Convert t 0..1 to arc length 0..d 30 | let elapsed_distance = t * spline_map.length; 31 | 32 | // Find closest interval 33 | let len = spline_map.steps.len(); 34 | let i = usize::min(len - 2, find_index(spline_map, elapsed_distance)); 35 | let start = spline_map.steps[i]; 36 | let end = spline_map.steps[i + 1]; 37 | 38 | // Use chordal catmull-rom if we have a window of 4 steps. 39 | // This reduces jitter by an order of magnitude. 40 | if i > 0 && i < spline_map.steps.len() - 2 { 41 | let prev = spline_map.steps[i - 1]; 42 | let next = spline_map.steps[i + 2]; 43 | catmull_rom_value( 44 | &prev.0, 45 | &start.0, 46 | &end.0, 47 | &next.0, 48 | prev.1, 49 | start.1, 50 | end.1, 51 | next.1, 52 | elapsed_distance, 53 | ) 54 | } else { 55 | // Lerp steps[i] and steps[i+1] 56 | // (will only be used if easing beyond the start or end) 57 | linear_value(&start.0, &end.0, start.1, end.1, elapsed_distance) 58 | } 59 | }) 60 | .unwrap_or(t) 61 | } 62 | 63 | // Find index for lookup in spline map with binary search. 64 | // Returns last index with d < distance. 65 | pub fn find_index(spline_map: &SplineMap, distance: f64) -> usize { 66 | let len = spline_map.steps.len(); 67 | let mut size = len; 68 | let mut base: usize = 0; 69 | while size > 1 { 70 | let half = size / 2; 71 | let mid = base + half; 72 | let step = spline_map.steps[mid]; 73 | if step.1 < distance { 74 | base = mid 75 | } 76 | size -= half; 77 | } 78 | base 79 | } 80 | 81 | impl SplineMap { 82 | // Make a spline map to map "spline time" 0..1 to arc length 0..d. 83 | // Integrates with Euler's rule. 84 | 85 | pub fn from_spline V>(f: F, rectify: bool) -> SplineMap { 86 | let mut steps = Vec::new(); 87 | let mut length: f64 = 0.0; 88 | let mut point = f(0.0); 89 | 90 | // Insert one negative point before 91 | let step = 1.0 / (SPLINE_SUBDIVISION as f64); 92 | steps.push((-step, -f(-step).distance_to(point))); 93 | steps.push((0.0, 0.0)); 94 | 95 | // Measure arc length of each segment 96 | let mut t = 0.0; 97 | for _i in 0..=SPLINE_SUBDIVISION { 98 | t += step; 99 | let next = f(t); 100 | let d = next.distance_to(point); 101 | point = next; 102 | 103 | length += d; 104 | steps.push((t, length)); 105 | } 106 | 107 | // Ignore one point after 108 | length = steps[steps.len() - 2].1; 109 | 110 | SplineMap { 111 | steps, 112 | length, 113 | rectify, 114 | } 115 | } 116 | 117 | // Make a spline map from a cubic bezier to map "spline time" 0..1 to arc length 0..d. 118 | // Uses analytic derivatives and simpson's rule for more accurate integration. 119 | // (only makes a difference for strongly cusped curves) 120 | 121 | pub fn from_bezier(b0: &V, b1: &V, b2: &V, b3: &V, rectify: bool) -> SplineMap { 122 | let mut steps = Vec::new(); 123 | let mut length: f64 = 0.0; 124 | let zero = b0.map(|_| V::Component::zero()); 125 | 126 | // Integrate arc length between t = a..b 127 | let integrate = |a, b| { 128 | // Get tangents at start, middle and end 129 | let dt0 = dt_cubic_bezier(b0, b1, b2, b3, a); 130 | let dt1 = dt_cubic_bezier(b0, b1, b2, b3, (a + b) / 2.0); 131 | let dt2 = dt_cubic_bezier(b0, b1, b2, b3, b); 132 | 133 | // Get magnitude 134 | let ds0 = zero.distance_to(dt0); 135 | let ds1 = zero.distance_to(dt1); 136 | let ds2 = zero.distance_to(dt2); 137 | 138 | // Simpson's 1/3 rule 139 | (ds0 + 4.0 * ds1 + ds2) * (b - a) / 6.0 140 | }; 141 | 142 | // Insert one negative point before 143 | let step = 1.0 / (SPLINE_SUBDIVISION as f64); 144 | steps.push((-step, -integrate(-step, 0.0))); 145 | steps.push((0.0, 0.0)); 146 | 147 | // Measure arc length of each segment 148 | let mut last = 0.0; 149 | let mut t = 0.0; 150 | for _i in 0..=SPLINE_SUBDIVISION { 151 | t += step; 152 | let d = integrate(last, t); 153 | last = t; 154 | 155 | length += d; 156 | steps.push((t, length)); 157 | } 158 | 159 | // Ignore one point after 160 | length = steps[steps.len() - 2].1; 161 | 162 | SplineMap { 163 | steps, 164 | length, 165 | rectify, 166 | } 167 | } 168 | } 169 | 170 | #[cfg(test)] 171 | mod tests { 172 | use super::{bezier::cubic_bezier, *}; 173 | use gee::en::Num as _; 174 | 175 | const MATCH_TOLERANCE: f64 = 1e-3; 176 | 177 | pub fn approx_eq(lhs: f64, rhs: f64, epsilon: f64) -> bool { 178 | lhs.is_finite() && rhs.is_finite() && ((lhs - epsilon)..(lhs + epsilon)).contains(&rhs) 179 | } 180 | 181 | pub fn integrate_length V>( 182 | from: f64, 183 | to: f64, 184 | divide: usize, 185 | f: F, 186 | ) -> f64 { 187 | let mut length: f64 = 0.0; 188 | let mut point = f(from); 189 | 190 | let mut t = from; 191 | let step = (to - from) / (divide as f64); 192 | 193 | // Measure arc length of each segment 194 | for _i in 0..divide { 195 | t += step; 196 | let next = f(t); 197 | let d = next.distance_to(point); 198 | 199 | length += d; 200 | point = next; 201 | } 202 | length 203 | } 204 | 205 | #[test] 206 | fn test_maps_match() { 207 | let b0 = (0f64, 0f64); 208 | let b1 = (50f64, -50f64); 209 | let b2 = (100f64, 100f64); 210 | let b3 = (150f64, 0f64); 211 | let spline_map = SplineMap::from_spline(|t| cubic_bezier(&b0, &b1, &b2, &b3, t), true); 212 | let bezier_map = SplineMap::from_bezier(&b0, &b1, &b2, &b3, true); 213 | let tolerance = MATCH_TOLERANCE * spline_map.length; 214 | let mut i = 0; 215 | for &s1 in spline_map.steps.iter() { 216 | let s2 = bezier_map.steps[i]; 217 | i += 1; 218 | println!("{:?} {:?}", s1, s2); 219 | assert!(approx_eq(s1.0, s2.0, MATCH_TOLERANCE)); 220 | assert!(approx_eq(s1.1, s2.1, tolerance)); 221 | } 222 | } 223 | 224 | #[test] 225 | fn test_spline_map() { 226 | let b0 = (0f64, 0f64); 227 | let b1 = (50f64, -50f64); 228 | let b2 = (100f64, 100f64); 229 | let b3 = (150f64, 0f64); 230 | let spline_map = SplineMap::from_spline(|t| cubic_bezier(&b0, &b1, &b2, &b3, t), true); 231 | 232 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 100, 1e-3); 233 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 20, 1e-3); 234 | } 235 | 236 | #[test] 237 | fn test_bezier_map() { 238 | let b0 = (0f64, 0f64); 239 | let b1 = (50f64, -50f64); 240 | let b2 = (100f64, 100f64); 241 | let b3 = (150f64, 0f64); 242 | let spline_map = SplineMap::from_bezier(&b0, &b1, &b2, &b3, true); 243 | 244 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 100, 1e-3); 245 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 20, 1e-3); 246 | } 247 | 248 | #[test] 249 | fn test_spline_map_cusp() { 250 | let b0 = (0f64, 0f64); 251 | let b1 = (50f64, -50f64); 252 | let b2 = (350f64, 100f64); 253 | let b3 = (150f64, 0f64); 254 | let spline_map = SplineMap::from_spline(|t| cubic_bezier(&b0, &b1, &b2, &b3, t), true); 255 | 256 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 100, 3e-2); 257 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 20, 3e-2); 258 | } 259 | 260 | #[test] 261 | fn test_bezier_map_cusp() { 262 | let b0 = (0f64, 0f64); 263 | let b1 = (50f64, -50f64); 264 | let b2 = (350f64, 100f64); 265 | let b3 = (150f64, 0f64); 266 | let spline_map = SplineMap::from_bezier(&b0, &b1, &b2, &b3, true); 267 | 268 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 100, 1e-2); 269 | test_with_spline_map(&spline_map, &b0, &b1, &b2, &b3, 20, 1e-2); 270 | } 271 | 272 | fn test_with_spline_map( 273 | spline_map: &SplineMap, 274 | b0: &(f64, f64), 275 | b1: &(f64, f64), 276 | b2: &(f64, f64), 277 | b3: &(f64, f64), 278 | subdivision: usize, 279 | tolerance: f64, 280 | ) { 281 | // Divide spline into N equal segments of arc length 282 | let step = spline_map.length / (subdivision as f64); 283 | let epsilon = tolerance; 284 | println!("Expected step size {}", step); 285 | 286 | // Check if each segment is the same length 287 | let mut min = 1000.0; 288 | let mut max = -1000.0; 289 | 290 | for i in 0..subdivision { 291 | let t1 = i.to_f64() / subdivision.to_f64(); 292 | let t2 = (i + 1).to_f64() / subdivision.to_f64(); 293 | 294 | let ease1 = spline_ease(spline_map, t1); 295 | let ease2 = spline_ease(spline_map, t2); 296 | 297 | // Get ground truth arc length 298 | let d = integrate_length(ease1, ease2, 128, |t| cubic_bezier(b0, b1, b2, b3, t)); 299 | let error = ((d - step) / step).abs(); 300 | 301 | // Track min/max error 302 | min = f64::min(min, error); 303 | max = f64::max(max, error); 304 | 305 | let warn = if error > epsilon { "⚠️" } else { "" }; 306 | println!( 307 | "t = {:.2}..{:.2} ±{:.6} ±{:.3}% {}", 308 | t1, 309 | t2, 310 | (d - step).abs(), 311 | error * 100.0, 312 | warn 313 | ); 314 | } 315 | println!("error min: {:.6}% max: {:.6}%", min * 100.0, max * 100.0); 316 | assert!(approx_eq(0.0, max, epsilon)); 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/structured/affine.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ease::Ease, interval::Interval, retargetable, structured::transform::TransformAnimation, 3 | Animation, 4 | }; 5 | use gee::{Point, Transform}; 6 | use std::time::Duration; 7 | 8 | // AffineAnimation: Affine Transformations made easy 9 | #[derive(Debug)] 10 | pub struct AffineAnimation { 11 | pub position: Box>>, 12 | pub transform: TransformAnimation, 13 | } 14 | 15 | impl AffineAnimation { 16 | pub fn from_values(position: Point, transform: Transform) -> Self { 17 | Self { 18 | position: Box::new(Interval::hold(position, Duration::ZERO)), 19 | transform: TransformAnimation::hold(transform), 20 | } 21 | } 22 | 23 | pub fn sample(&self, elapsed: Duration) -> Transform { 24 | let position = self.position.sample(elapsed); 25 | self.transform 26 | .sample(elapsed) 27 | .pre_translate(-position.x, -position.y) 28 | .post_translate(position.x, position.y) 29 | } 30 | 31 | pub fn transform( 32 | &mut self, 33 | interrupt_t: Duration, 34 | transition_t: Duration, 35 | target: Transform, 36 | ease: Option, 37 | ) { 38 | self.transform 39 | .retarget(interrupt_t, transition_t, target, ease); 40 | } 41 | 42 | retargetable!(position, Animation, Point); 43 | } 44 | -------------------------------------------------------------------------------- /src/structured/clock.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ease::Ease, interval::Interval, retargetable, spline::bezier_ease::BezierEase, Animation, 3 | BoundedAnimation, 4 | }; 5 | use replace_with::replace_with_or_abort; 6 | use std::time::Duration; 7 | 8 | const TRANSITION_TIME: f64 = 0.5; 9 | 10 | // Clock: A more convenient animation clock 11 | #[derive(Debug)] 12 | pub struct Clock { 13 | pub now: Duration, 14 | pub total_elapsed: Duration, 15 | pub rate_of_travel: Box>, 16 | interrupt_t: Duration, 17 | } 18 | 19 | impl Default for Clock { 20 | fn default() -> Self { 21 | Self { 22 | now: Duration::ZERO, 23 | total_elapsed: Duration::ZERO, 24 | rate_of_travel: Box::new(Interval::hold(1.0, Duration::ZERO)), 25 | interrupt_t: Duration::ZERO, 26 | } 27 | } 28 | } 29 | 30 | impl Clock { 31 | pub fn new(now: Duration, rate_of_travel: f64) -> Self { 32 | Self { 33 | now, 34 | total_elapsed: Duration::ZERO, 35 | rate_of_travel: Box::new(Interval::hold(rate_of_travel, Duration::ZERO)), 36 | interrupt_t: Duration::ZERO, 37 | } 38 | } 39 | 40 | pub fn time_passed(&mut self, elapsed: Duration) { 41 | self.now += elapsed.mul_f64(self.rate_of_travel.sample(self.total_elapsed)); 42 | self.total_elapsed += elapsed; 43 | } 44 | 45 | pub fn normal_speed(&mut self) { 46 | self.rate_of_travel( 47 | self.total_elapsed, 48 | Duration::from_secs_f64(1.0), 49 | 1.0, 50 | Some(BezierEase::ease_in_out()), 51 | ); 52 | } 53 | 54 | pub fn slow_speed(&mut self) { 55 | self.rate_of_travel( 56 | self.total_elapsed, 57 | Duration::from_secs_f64(1.0), 58 | 0.1, 59 | Some(BezierEase::ease_in_out()), 60 | ); 61 | } 62 | 63 | pub fn bullet_time(&mut self, duration: Duration) { 64 | self.temporary_speed_change(0.1, duration); 65 | } 66 | 67 | pub fn fast_forward(&mut self, duration: Duration) { 68 | self.temporary_speed_change(2.0, duration); 69 | } 70 | 71 | pub fn rewind(&mut self, duration: Duration) { 72 | self.temporary_speed_change(-1.0, duration); 73 | } 74 | 75 | fn temporary_speed_change(&mut self, target: f64, duration: Duration) { 76 | let interrupt_t = self.total_elapsed; 77 | let transition_t = Duration::from_secs_f64( 78 | TRANSITION_TIME 79 | .min(duration.as_secs_f64() / 2.0) 80 | .min(self.total_elapsed.as_secs_f64() - self.interrupt_t.as_secs_f64()), 81 | ); 82 | let from = self.rate_of_travel.sample(self.total_elapsed); 83 | 84 | replace_with_or_abort(&mut self.rate_of_travel, |rate_of_travel| { 85 | Box::new( 86 | rate_of_travel.interrupt( 87 | Interval::from_values( 88 | transition_t, 89 | from, 90 | target, 91 | Some(BezierEase::ease_in_out()), 92 | ) 93 | .chain(Interval::hold(target, duration - (transition_t * 2))) 94 | .chain(Interval::from_values( 95 | transition_t, 96 | target, 97 | 1.0, 98 | Some(BezierEase::ease_in_out()), 99 | )), 100 | interrupt_t, 101 | transition_t, 102 | ), 103 | ) 104 | }); 105 | } 106 | 107 | retargetable!(rate_of_travel, Animation, f64); 108 | } 109 | -------------------------------------------------------------------------------- /src/structured/flip.rs: -------------------------------------------------------------------------------- 1 | use crate::{constant::Constant, ease::Ease, interval::Interval, retargetable, Animation}; 2 | use gee::{Angle, Point, Transform, Transform3d}; 3 | use std::time::Duration; 4 | 5 | const MAX_DISTORT: f32 = 0.0005; 6 | 7 | // FlipAnimation: Examining correct use of perspective 8 | #[derive(Debug)] 9 | pub struct FlipAnimation { 10 | pub position: Box>>, 11 | // This is the angle describing the axis about which the face of the card will rotate 12 | pub angle: Box>>, // can this be animated? Work it out! 13 | // This is the angle of rotation about the axis described by "angle" 14 | pub flip: Box>>, // amount of flip which has occurred 15 | pub scale: Box>, // Scaling 16 | } 17 | 18 | impl FlipAnimation { 19 | pub fn from_values( 20 | position: Point, 21 | angle: Angle, 22 | flip: Angle, 23 | scale: f32, 24 | ) -> Self { 25 | Self { 26 | position: Box::new(Constant::new(position)), 27 | angle: Box::new(Constant::new(angle)), 28 | flip: Box::new(Constant::new(flip)), 29 | scale: Box::new(Constant::new(scale)), 30 | } 31 | } 32 | 33 | pub fn from_animations( 34 | position: Box>>, 35 | angle: Box>>, 36 | flip: Box>>, 37 | scale: Box>, 38 | ) -> Self { 39 | Self { 40 | position, 41 | angle, 42 | flip, 43 | scale, 44 | } 45 | } 46 | 47 | pub fn sample(&self, elapsed: Duration) -> Transform3d { 48 | let position = self.position.sample(elapsed); 49 | let angle = self.angle.sample(elapsed); 50 | let scale = self.scale.sample(elapsed); 51 | let scale_percent = self.get_scale_percent(elapsed); 52 | let faceup = self.is_faceup(elapsed); 53 | let distortion = Self::get_distortion(scale_percent, faceup, self.is_returning(elapsed)); 54 | // When facedown, the resting angle will be offset by 2x the angle of rotation 55 | let offset = (Angle::PI() - (angle * 2.0)) * (1.0 - faceup as i32 as f32); 56 | 57 | // Translate to origin, rotate & scale 58 | let pre_transform: Transform3d = Transform::from_translation(-position.x, -position.y) 59 | .post_rotate(angle + offset, Point::zero()) 60 | .post_scale(scale, scale * (1.0 - scale_percent)) 61 | .into(); 62 | 63 | // Unrotate, translate back to self.position 64 | let post_transform: Transform3d = Transform::from_rotation(-angle, Point::zero()) 65 | .post_translate(position.x, position.y) 66 | .into(); 67 | 68 | pre_transform 69 | // Distort (but only on y, due to rotation) 70 | .post_mul(Transform3d { 71 | m24: distortion, 72 | ..Transform3d::identity() 73 | }) 74 | .post_mul(post_transform) 75 | } 76 | 77 | pub fn is_faceup(&self, elapsed: Duration) -> bool { 78 | let angle = self.flip.sample(elapsed).normalize(); 79 | angle > -Angle::FRAC_PI_2() && angle < Angle::FRAC_PI_2() 80 | } 81 | 82 | pub fn is_returning(&self, elapsed: Duration) -> bool { 83 | self.flip.sample(elapsed).normalize().radians() < 0.0 84 | } 85 | 86 | fn get_distortion(flip_percent: f32, faceup: bool, returning: bool) -> f32 { 87 | flip_percent 88 | * if faceup ^ returning { 89 | -MAX_DISTORT 90 | } else { 91 | MAX_DISTORT 92 | } 93 | } 94 | 95 | pub fn get_scale_percent(&self, elapsed: Duration) -> f32 { 96 | let flip = self.flip.sample(elapsed).normalize().radians(); 97 | 98 | if self.is_faceup(elapsed) { 99 | // in range -pi/2 to +pi/2 100 | (flip / std::f32::consts::FRAC_PI_2).abs() 101 | } else { 102 | // in range -pi - -pi/2 & pi/2+ 103 | 1.0 - ((flip.abs() - std::f32::consts::FRAC_PI_2) / std::f32::consts::FRAC_PI_2) 104 | } 105 | } 106 | 107 | retargetable!(position, Animation, Point); 108 | retargetable!(angle, Animation, Angle); 109 | retargetable!(flip, Animation, Angle); 110 | retargetable!(scale, Animation, f32); 111 | } 112 | -------------------------------------------------------------------------------- /src/structured/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod affine; 2 | pub mod clock; 3 | pub mod flip; 4 | pub mod path; 5 | pub mod radial; 6 | pub mod retarget; 7 | pub mod transform; 8 | 9 | pub use self::{affine::*, clock::*, flip::*, path::*, radial::*, retarget::*, transform::*}; 10 | -------------------------------------------------------------------------------- /src/structured/path.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | constant::Constant, 3 | ease::Ease, 4 | interval::Interval, 5 | interval_track::{BookendStyle, IntervalTrack}, 6 | retargetable, Animation, 7 | }; 8 | use gee::{en::Num, Angle, Point, Transform}; 9 | use std::{f64::consts::TAU, time::Duration}; 10 | 11 | // PathAnimation: Animating along a path made simple 12 | // The texture will rotate to follow the path using some RotationStyle, 13 | // but can also be rotated independently of the path's natural curve 14 | #[derive(Debug)] 15 | pub enum RotationStyle { 16 | // The texture does not rotate 17 | NoRotation, 18 | // The texture rotates to align with the path (forward sampling) 19 | FollowPath, 20 | // The texture "overcorrects" when rounding curves 21 | Overcorrect, 22 | } 23 | 24 | #[derive(Debug)] 25 | pub struct PathAnimation { 26 | pub position: Box>>, 27 | pub angle: Box>>, 28 | style: RotationStyle, 29 | } 30 | 31 | impl PathAnimation { 32 | pub fn new(position: Point, angle: Angle, style: RotationStyle) -> Self { 33 | Self { 34 | position: Box::new(Constant::new(position)), 35 | angle: Box::new(Constant::new(angle)), 36 | style, 37 | } 38 | } 39 | 40 | pub fn from_values( 41 | duration: Duration, 42 | points: Vec, 43 | angles: Vec, 44 | style: RotationStyle, 45 | ) -> Self { 46 | Self { 47 | position: Box::new(IntervalTrack::path( 48 | duration, 49 | points, 50 | BookendStyle::Repeat, 51 | None, 52 | false, 53 | )), 54 | angle: Box::new(IntervalTrack::path( 55 | duration, 56 | angles, 57 | BookendStyle::Repeat, 58 | None, 59 | false, 60 | )), 61 | style, 62 | } 63 | } 64 | 65 | pub fn sample_position(&self, elapsed: Duration) -> Point { 66 | self.position.sample(elapsed) 67 | } 68 | 69 | pub fn sample_transform(&self, elapsed: Duration, sample_delta: Duration) -> Transform { 70 | Transform::from_rotation( 71 | self.get_angle(elapsed, sample_delta), 72 | self.sample_position(elapsed), 73 | ) 74 | } 75 | 76 | pub fn get_angle(&self, elapsed: Duration, sample_delta: Duration) -> Angle { 77 | match self.style { 78 | RotationStyle::NoRotation => self.angle.sample(elapsed), 79 | RotationStyle::FollowPath => { 80 | let delta = 81 | self.position.sample(elapsed + sample_delta) - self.position.sample(elapsed); 82 | delta.angle() + self.angle.sample(elapsed) 83 | } 84 | RotationStyle::Overcorrect => { 85 | let position = self.position.sample(elapsed); 86 | let back_angle = (position - self.position.sample(elapsed - sample_delta)).angle(); 87 | let front_angle_difference = (back_angle.radians() 88 | - (self.position.sample(elapsed + sample_delta) - position) 89 | .angle() 90 | .radians()) 91 | .to_f64(); 92 | 93 | let shortest_delta = (front_angle_difference.abs() > std::f64::consts::PI) 94 | .then(|| { 95 | front_angle_difference 96 | .is_sign_positive() 97 | .then(|| TAU - front_angle_difference) 98 | .unwrap_or(-TAU - front_angle_difference) 99 | }) 100 | .unwrap_or(front_angle_difference * -1.0); 101 | 102 | Angle::from_radians(shortest_delta * 1.15).to_f32() 103 | + back_angle 104 | + self.angle.sample(elapsed) 105 | } 106 | } 107 | } 108 | 109 | retargetable!(position, Animation, Point); 110 | retargetable!(angle, Animation, Angle); 111 | } 112 | 113 | impl Animation> for PathAnimation { 114 | fn sample(&self, elapsed: Duration) -> Point { 115 | self.sample_position(elapsed) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/structured/radial.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | constant::Constant, ease::Ease, interval::Interval, retargetable, 3 | spline::bezier_ease::BezierEase, Animation, BoundedAnimation, 4 | }; 5 | use gee::{Angle, Circle, Point}; 6 | use replace_with::replace_with_or_abort; 7 | use std::time::Duration; 8 | 9 | // Radial: A fun test for Celerity 10 | // An animation which travels around a circle, varying its angle and distance from an origin point 11 | #[derive(Debug)] 12 | pub struct Radial { 13 | pub origin: Box>>, 14 | pub angle: Box>>, 15 | pub distance: Box>, 16 | } 17 | 18 | impl Radial { 19 | pub fn new(origin: Point, angle: Angle, distance: f32) -> Self { 20 | Self { 21 | origin: Box::new(Constant::new(origin)), 22 | angle: Box::new(Constant::new(angle)), 23 | distance: Box::new(Constant::new(distance)), 24 | } 25 | } 26 | 27 | pub fn clockwise(&mut self, interrupt_t: Duration, speed: Duration) { 28 | let interrupt_v = self.angle.sample(interrupt_t); 29 | 30 | replace_with_or_abort(&mut self.angle, |angle| { 31 | Box::new( 32 | angle 33 | .interrupt( 34 | Interval::from_values( 35 | speed, 36 | interrupt_v, 37 | interrupt_v - (Angle::PI() * 2.0), 38 | None, 39 | ), 40 | interrupt_t, 41 | speed, 42 | ) 43 | .cycle(), 44 | ) 45 | }); 46 | } 47 | 48 | pub fn anticlockwise(&mut self, interrupt_t: Duration, speed: Duration) { 49 | let interrupt_v = self.angle.sample(interrupt_t); 50 | 51 | replace_with_or_abort(&mut self.angle, |angle| { 52 | Box::new( 53 | angle 54 | .interrupt( 55 | Interval::from_values( 56 | speed, 57 | interrupt_v, 58 | interrupt_v + (Angle::PI() * 2.0), 59 | None, 60 | ), 61 | interrupt_t, 62 | speed, 63 | ) 64 | .cycle(), 65 | ) 66 | }); 67 | } 68 | 69 | pub fn to_and_from(&mut self, to: f32, from: f32, interrupt_t: Duration, speed: Duration) { 70 | let interrupt_v = self.distance.sample(interrupt_t); 71 | 72 | replace_with_or_abort(&mut self.distance, |distance| { 73 | Box::new( 74 | distance.interrupt( 75 | Interval::from_values(speed / 2, interrupt_v, to, Some(BezierEase::ease_in())) 76 | .chain( 77 | Interval::from_values(speed / 2, to, from, None) 78 | .chain(Interval::from_values(speed / 2, from, to, None)) 79 | .cycle(), 80 | ), 81 | interrupt_t, 82 | speed, 83 | ), 84 | ) 85 | }); 86 | } 87 | 88 | retargetable!(distance, Animation, f32); 89 | retargetable!(origin, Animation, Point); 90 | retargetable!(angle, Animation, Angle); 91 | } 92 | 93 | impl Animation> for Radial { 94 | fn sample(&self, elapsed: Duration) -> Point { 95 | Circle::circle_points( 96 | &Circle::new(self.origin.sample(elapsed), self.distance.sample(elapsed)), 97 | 1, 98 | self.angle.sample(elapsed), 99 | ) 100 | .last() 101 | .unwrap() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/structured/retarget.rs: -------------------------------------------------------------------------------- 1 | // This macro generates the code required for smoothly interrupting 2 | // an animation with a new destination or animation. 3 | // 4 | // Simply add to any struct with members that implement Animation: 5 | // retargetable!([member_identifier], [animation_type], [animatable_type]); 6 | 7 | #[macro_export] 8 | macro_rules! retargetable { 9 | ( $anim:ident, $a:ty, $v:ty ) => { 10 | pub fn $anim( 11 | &mut self, 12 | interrupt_t: Duration, 13 | transition_t: Duration, 14 | target: $v, 15 | ease: Option, 16 | ) { 17 | let interrupt_v = self.$anim.sample(interrupt_t); 18 | 19 | self.$anim.replace_with(|anim| { 20 | Box::new(anim.interrupt( 21 | Interval::from_values(transition_t, interrupt_v, target, ease), 22 | interrupt_t, 23 | transition_t, 24 | )) 25 | }); 26 | } 27 | 28 | paste::paste! { 29 | pub fn [<$anim _animation>]( 30 | &mut self, 31 | interrupt_t: Duration, 32 | transition_t: Duration, 33 | new_animation: Box>, 34 | ) { 35 | self.$anim.replace_with(|anim| { 36 | Box::new(anim.interrupt(new_animation, interrupt_t, transition_t)) 37 | }); 38 | } 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/structured/transform.rs: -------------------------------------------------------------------------------- 1 | use crate::{ease::Ease, interval::Interval, retargetable, Animation}; 2 | use gee::{Angle, DecomposedTransform, Transform, Vector}; 3 | use std::time::Duration; 4 | 5 | // TransformAnimation: Working with Affine transformations 6 | #[derive(Debug)] 7 | pub struct TransformAnimation { 8 | pub translate: Box>>, 9 | pub rotate: Box>>, 10 | pub scale: Box>>, 11 | pub skew: Box>>, 12 | } 13 | 14 | impl TransformAnimation { 15 | pub fn new( 16 | start: Duration, 17 | duration: Duration, 18 | from: Transform, 19 | to: Transform, 20 | ease: Option, 21 | ) -> Self { 22 | let DecomposedTransform { 23 | translation: ta, 24 | rotation: ra, 25 | skew: ka, 26 | scale: sa, 27 | } = from.decompose(); 28 | let DecomposedTransform { 29 | translation: tb, 30 | rotation: rb, 31 | skew: kb, 32 | scale: sb, 33 | } = to.decompose(); 34 | Self { 35 | translate: Box::new(Interval::new( 36 | start, 37 | duration, 38 | ta, 39 | tb, 40 | ease.clone(), 41 | None, 42 | None, 43 | )), 44 | rotate: Box::new(Interval::new( 45 | start, 46 | duration, 47 | ra, 48 | rb, 49 | ease.clone(), 50 | None, 51 | None, 52 | )), 53 | scale: Box::new(Interval::new( 54 | start, 55 | duration, 56 | sa, 57 | sb, 58 | ease.clone(), 59 | None, 60 | None, 61 | )), 62 | skew: Box::new(Interval::new( 63 | start, 64 | duration, 65 | ka, 66 | kb, 67 | ease.clone(), 68 | None, 69 | None, 70 | )), 71 | } 72 | } 73 | 74 | pub fn identity() -> Self { 75 | let identity = DecomposedTransform::identity(); 76 | Self { 77 | translate: Box::new(Interval::hold(identity.translation, Duration::ZERO)), 78 | rotate: Box::new(Interval::hold(identity.rotation, Duration::ZERO)), 79 | scale: Box::new(Interval::hold(identity.scale, Duration::ZERO)), 80 | skew: Box::new(Interval::hold(identity.skew, Duration::ZERO)), 81 | } 82 | } 83 | 84 | pub fn hold(value: Transform) -> Self { 85 | let DecomposedTransform { 86 | translation, 87 | rotation, 88 | skew, 89 | scale, 90 | } = value.decompose(); 91 | Self { 92 | translate: Box::new(Interval::hold(translation, Duration::ZERO)), 93 | rotate: Box::new(Interval::hold(rotation, Duration::ZERO)), 94 | scale: Box::new(Interval::hold(scale, Duration::ZERO)), 95 | skew: Box::new(Interval::hold(skew, Duration::ZERO)), 96 | } 97 | } 98 | 99 | retargetable!(translate, Animation, Vector); 100 | retargetable!(rotate, Animation, Angle); 101 | retargetable!(scale, Animation, Vector); 102 | retargetable!(skew, Animation, Angle); 103 | 104 | pub fn retarget( 105 | &mut self, 106 | interrupt_t: Duration, 107 | transition_t: Duration, 108 | target: Transform, 109 | ease: Option, 110 | ) { 111 | let DecomposedTransform { 112 | translation, 113 | rotation, 114 | skew, 115 | scale, 116 | } = target.decompose(); 117 | self.translate(interrupt_t, transition_t, translation, ease.clone()); 118 | self.rotate(interrupt_t, transition_t, rotation, ease.clone()); 119 | self.scale(interrupt_t, transition_t, scale, ease.clone()); 120 | self.skew(interrupt_t, transition_t, skew, ease.clone()); 121 | } 122 | 123 | pub fn sample(&self, elapsed: Duration) -> Transform { 124 | Transform::from_decomposed(DecomposedTransform { 125 | translation: self.translate.sample(elapsed), 126 | rotation: self.rotate.sample(elapsed), 127 | skew: self.skew.sample(elapsed), 128 | scale: self.scale.sample(elapsed), 129 | }) 130 | } 131 | } 132 | --------------------------------------------------------------------------------