1use std::{ops::RangeInclusive, time::Duration};
2
3use crate::{Hsla, Motion, Pixels, Rems, Rgba, SpringDescription};
4
5const CRITICAL_DAMPING_TOLERANCE: f32 = 1e-4;
6pub(crate) const DEFAULT_SPRING_EPSILON: f32 = 0.001;
7
8#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct SpringConfig {
33 pub stiffness: f32,
36 pub damping: f32,
39 pub mass: f32,
41}
42
43pub const fn spring(stiffness: f32, damping: f32, mass: f32) -> SpringConfig {
48 SpringConfig::new(stiffness, damping, mass)
49}
50
51impl SpringConfig {
52 pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
54 Self {
55 stiffness,
56 damping,
57 mass,
58 }
59 }
60
61 pub fn canonical(&self) -> (f32, f32) {
68 let natural_frequency = (self.stiffness / self.mass).sqrt();
69 let damping_ratio = self.damping / (2.0 * (self.stiffness * self.mass).sqrt());
70 (natural_frequency, damping_ratio)
71 }
72
73 pub fn step(&self, state: SpringState, target: f32, delta_time: f32) -> SpringState {
93 let propagator = self.propagator(delta_time);
94 let displacement = state.position - target;
95
96 SpringState {
97 position: target + propagator[0][0] * displacement + propagator[0][1] * state.velocity,
98 velocity: propagator[1][0] * displacement + propagator[1][1] * state.velocity,
99 }
100 }
101
102 pub fn step_ramp(
122 &self,
123 state: SpringState,
124 target: f32,
125 target_velocity: f32,
126 delta_time: f32,
127 ) -> SpringState {
128 let (natural_frequency, damping_ratio) = self.canonical();
129 let steady_state_lag = -2.0 * damping_ratio * target_velocity / natural_frequency;
130 let displacement = state.position - target - steady_state_lag;
131 let velocity = state.velocity - target_velocity;
132 let propagator = self.propagator(delta_time);
133 let target = target + target_velocity * delta_time;
134
135 SpringState {
136 position: target
137 + steady_state_lag
138 + propagator[0][0] * displacement
139 + propagator[0][1] * velocity,
140 velocity: target_velocity
141 + propagator[1][0] * displacement
142 + propagator[1][1] * velocity,
143 }
144 }
145
146 pub fn propagator(&self, delta_time: f32) -> [[f32; 2]; 2] {
162 let (natural_frequency, damping_ratio) = self.canonical();
163
164 if damping_ratio < 1.0 - CRITICAL_DAMPING_TOLERANCE {
165 let decay = damping_ratio * natural_frequency;
166 let damped_frequency = natural_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
167 let exponential = (-decay * delta_time).exp();
168 let (sine, cosine) = (damped_frequency * delta_time).sin_cos();
169 let sine_over_frequency = sine / damped_frequency;
170
171 [
172 [
173 exponential * (cosine + decay * sine_over_frequency),
174 exponential * sine_over_frequency,
175 ],
176 [
177 -exponential * natural_frequency * natural_frequency * sine_over_frequency,
178 exponential * (cosine - decay * sine_over_frequency),
179 ],
180 ]
181 } else if damping_ratio > 1.0 + CRITICAL_DAMPING_TOLERANCE {
182 let root = (damping_ratio * damping_ratio - 1.0).sqrt();
183 let root_sum = damping_ratio + root;
184 let slow_root = -natural_frequency / root_sum;
185 let fast_root = -natural_frequency * root_sum;
186 let denominator = slow_root - fast_root;
187 let slow_exponential = (slow_root * delta_time).exp();
188 let fast_exponential = (fast_root * delta_time).exp();
189
190 [
191 [
192 (-fast_root * slow_exponential + slow_root * fast_exponential) / denominator,
193 (slow_exponential - fast_exponential) / denominator,
194 ],
195 [
196 slow_root * fast_root * (fast_exponential - slow_exponential) / denominator,
197 (slow_root * slow_exponential - fast_root * fast_exponential) / denominator,
198 ],
199 ]
200 } else {
201 let exponential = (-natural_frequency * delta_time).exp();
202
203 [
204 [
205 exponential * (1.0 + natural_frequency * delta_time),
206 exponential * delta_time,
207 ],
208 [
209 -exponential * natural_frequency * natural_frequency * delta_time,
210 exponential * (1.0 - natural_frequency * delta_time),
211 ],
212 ]
213 }
214 }
215
216 pub fn is_settled(&self, state: SpringState, target: f32, epsilon: f32) -> bool {
234 let (natural_frequency, _) = self.canonical();
235 epsilon.is_finite()
236 && epsilon >= 0.0
237 && (state.position - target).abs() <= epsilon
238 && state.velocity.abs() <= epsilon * natural_frequency
239 }
240
241 pub fn settle_time(&self, state: SpringState, target: f32, epsilon: f32) -> Duration {
266 let displacement = state.position - target;
267 if displacement == 0.0 && state.velocity == 0.0 {
268 return Duration::ZERO;
269 }
270
271 let (natural_frequency, damping_ratio) = self.canonical();
272 if !natural_frequency.is_finite()
273 || natural_frequency <= 0.0
274 || !damping_ratio.is_finite()
275 || damping_ratio <= 0.0
276 || !epsilon.is_finite()
277 || epsilon <= 0.0
278 {
279 return Duration::MAX;
280 }
281
282 let velocity_threshold = epsilon * natural_frequency;
283
284 if damping_ratio < 1.0 - CRITICAL_DAMPING_TOLERANCE {
285 let decay = damping_ratio * natural_frequency;
286 let damped_frequency = natural_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
287 let sine_coefficient = (state.velocity + decay * displacement) / damped_frequency;
288 let position_envelope = displacement.hypot(sine_coefficient);
289 let velocity_cosine = damped_frequency * sine_coefficient - decay * displacement;
290 let velocity_sine = -damped_frequency * displacement - decay * sine_coefficient;
291 let velocity_envelope = velocity_cosine.hypot(velocity_sine);
292
293 find_settle_time(
294 epsilon,
295 velocity_threshold,
296 0.0,
297 natural_frequency,
298 move |time| {
299 let exponential = (-decay * time).exp();
300 (
301 position_envelope * exponential,
302 velocity_envelope * exponential,
303 )
304 },
305 )
306 } else if damping_ratio > 1.0 + CRITICAL_DAMPING_TOLERANCE {
307 let root = (damping_ratio * damping_ratio - 1.0).sqrt();
308 let root_sum = damping_ratio + root;
309 let slow_root = -natural_frequency / root_sum;
310 let fast_root = -natural_frequency * root_sum;
311 let denominator = slow_root - fast_root;
312 let slow_coefficient = (state.velocity - fast_root * displacement) / denominator;
313 let fast_coefficient = (slow_root * displacement - state.velocity) / denominator;
314
315 find_settle_time(
316 epsilon,
317 velocity_threshold,
318 0.0,
319 natural_frequency,
320 move |time| {
321 let slow_term = slow_coefficient.abs() * (slow_root * time).exp();
322 let fast_term = fast_coefficient.abs() * (fast_root * time).exp();
323 (
324 slow_term + fast_term,
325 slow_root.abs() * slow_term + fast_root.abs() * fast_term,
326 )
327 },
328 )
329 } else {
330 let linear_coefficient = state.velocity + natural_frequency * displacement;
331 let position_constant = displacement.abs();
332 let position_linear = linear_coefficient.abs();
333 let velocity_constant = (linear_coefficient - natural_frequency * displacement).abs();
334 let velocity_linear = natural_frequency * linear_coefficient.abs();
335 let position_decay_start =
336 envelope_decay_start(position_constant, position_linear, natural_frequency);
337 let velocity_decay_start =
338 envelope_decay_start(velocity_constant, velocity_linear, natural_frequency);
339
340 find_settle_time(
341 epsilon,
342 velocity_threshold,
343 position_decay_start.max(velocity_decay_start),
344 natural_frequency,
345 move |time| {
346 let exponential = (-natural_frequency * time).exp();
347 (
348 (position_constant + position_linear * time) * exponential,
349 (velocity_constant + velocity_linear * time) * exponential,
350 )
351 },
352 )
353 }
354 }
355}
356
357#[derive(Clone, Copy, Debug, Default, PartialEq)]
362pub struct SpringState {
363 pub position: f32,
365 pub velocity: f32,
367}
368
369pub trait SpringTarget: 'static {
386 type Output;
388
389 fn target(&self) -> f32;
391
392 fn resolve(&self, value: f32) -> Self::Output;
394}
395
396impl SpringTarget for f32 {
397 type Output = f32;
398
399 fn target(&self) -> f32 {
400 *self
401 }
402
403 fn resolve(&self, value: f32) -> Self::Output {
404 value
405 }
406}
407
408impl SpringTarget for Pixels {
409 type Output = Pixels;
410
411 fn target(&self) -> f32 {
412 self.as_f32()
413 }
414
415 fn resolve(&self, value: f32) -> Self::Output {
416 Pixels::from(value)
417 }
418}
419
420impl SpringTarget for Rems {
421 type Output = Rems;
422
423 fn target(&self) -> f32 {
424 self.0
425 }
426
427 fn resolve(&self, value: f32) -> Self::Output {
428 Rems(value)
429 }
430}
431
432impl SpringTarget for bool {
433 type Output = AnimationPhase;
434
435 fn target(&self) -> f32 {
436 if *self { 1.0 } else { 0.0 }
437 }
438
439 fn resolve(&self, value: f32) -> Self::Output {
440 AnimationPhase(value)
441 }
442}
443
444#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
462pub struct AnimationPhase(
463 pub f32,
465);
466
467impl AnimationPhase {
468 pub fn clamp(self, range: RangeInclusive<f32>) -> Self {
472 let (first, second) = range.into_inner();
473 Self(self.0.clamp(first.min(second), first.max(second)))
474 }
475
476 pub fn interpolate<T: Interpolate>(self, from: T, to: T) -> T {
481 T::interpolate(from, to, self.0)
482 }
483
484 pub fn interpolate_clamped<T: Interpolate>(self, from: T, to: T) -> T {
489 T::interpolate(from, to, self.0.clamp(0.0, 1.0))
490 }
491
492 pub fn interpolate_between<T: Interpolate>(
497 self,
498 range: RangeInclusive<f32>,
499 from: T,
500 to: T,
501 ) -> T {
502 let (start, end) = range.into_inner();
503 let phase = if start == end {
504 if self.0 < start { 0.0 } else { 1.0 }
505 } else {
506 (self.0 - start) / (end - start)
507 };
508 T::interpolate(from, to, phase)
509 }
510
511 pub fn interpolate_between_clamped<T: Interpolate>(
517 self,
518 range: RangeInclusive<f32>,
519 from: T,
520 to: T,
521 ) -> T {
522 let (start, end) = range.into_inner();
523 let phase = if start == end {
524 if self.0 < start { 0.0 } else { 1.0 }
525 } else {
526 ((self.0 - start) / (end - start)).clamp(0.0, 1.0)
527 };
528 T::interpolate(from, to, phase)
529 }
530}
531
532impl From<f32> for AnimationPhase {
533 fn from(value: f32) -> Self {
534 Self(value)
535 }
536}
537
538impl From<bool> for AnimationPhase {
539 fn from(value: bool) -> Self {
540 Self(if value { 1.0 } else { 0.0 })
541 }
542}
543
544impl SpringTarget for AnimationPhase {
545 type Output = AnimationPhase;
546
547 fn target(&self) -> f32 {
548 self.0
549 }
550
551 fn resolve(&self, value: f32) -> Self::Output {
552 Self(value)
553 }
554}
555
556pub trait Interpolate: Sized {
571 fn interpolate(from: Self, to: Self, phase: f32) -> Self;
575}
576
577impl Interpolate for f32 {
578 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
579 from + (to - from) * phase
580 }
581}
582
583impl Interpolate for Pixels {
584 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
585 from + (to - from) * phase
586 }
587}
588
589impl Interpolate for Rems {
590 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
591 from + (to - from) * phase
592 }
593}
594
595impl Interpolate for Rgba {
596 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
597 Self::new(
598 f32::interpolate(from.color.red, to.color.red, phase),
599 f32::interpolate(from.color.green, to.color.green, phase),
600 f32::interpolate(from.color.blue, to.color.blue, phase),
601 f32::interpolate(from.alpha, to.alpha, phase),
602 )
603 }
604}
605
606impl Interpolate for Hsla {
607 fn interpolate(from: Self, to: Self, phase: f32) -> Self {
608 use palette::RgbHue;
609 let from_h = from.color.hue.into_degrees() / 360.0;
610 let to_h = to.color.hue.into_degrees() / 360.0;
611 let hue_delta = (to_h - from_h + 0.5).rem_euclid(1.0) - 0.5;
612 Self::new(
613 RgbHue::from_degrees((from_h + hue_delta * phase).rem_euclid(1.0) * 360.0),
614 f32::interpolate(from.color.saturation, to.color.saturation, phase),
615 f32::interpolate(from.color.lightness, to.color.lightness, phase),
616 f32::interpolate(from.alpha, to.alpha, phase),
617 )
618 }
619}
620
621#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
625pub enum SpringPlayback {
626 #[default]
628 Running,
629 Paused,
631 Stopped,
633 Completed,
635 Cancelled,
637}
638
639#[derive(Clone, Debug)]
683pub struct SpringAnimation<T = ()> {
684 pub(crate) motion: Motion<SpringDescription>,
685 pub(crate) target: T,
686 pub(crate) initial: Option<f32>,
687 pub(crate) playback: SpringPlayback,
688}
689
690impl SpringAnimation<()> {
691 pub fn new(config: SpringConfig) -> Self {
695 Self {
696 motion: Motion::spring(config),
697 target: (),
698 initial: None,
699 playback: SpringPlayback::Running,
700 }
701 }
702
703 pub fn to<T: SpringTarget>(self, target: T) -> SpringAnimation<T> {
709 let SpringAnimation {
710 motion,
711 target: (),
712 initial,
713 playback,
714 } = self;
715 SpringAnimation {
716 motion,
717 target,
718 initial,
719 playback,
720 }
721 }
722}
723
724impl<T> SpringAnimation<T> {
725 pub fn with_epsilon(mut self, epsilon: f32) -> Self {
730 self.motion = self.motion.with_epsilon(epsilon);
731 self
732 }
733
734 pub fn playback(mut self, playback: SpringPlayback) -> Self {
738 self.playback = playback;
739 self
740 }
741}
742
743impl<T: SpringTarget> SpringAnimation<T> {
744 pub fn from(mut self, initial: T) -> Self {
749 self.initial = Some(initial.target());
750 self
751 }
752}
753
754pub fn sampled_easing(config: SpringConfig, epsilon: f32) -> (Duration, impl Fn(f32) -> f32) {
776 let initial_state = SpringState {
777 position: 0.0,
778 velocity: 0.0,
779 };
780 let duration = config.settle_time(initial_state, 1.0, epsilon);
781 let duration_seconds = duration.as_secs_f32();
782
783 (duration, move |progress| {
784 if progress <= 0.0 {
785 0.0
786 } else if progress >= 1.0 {
787 1.0
788 } else {
789 config
790 .step(initial_state, 1.0, progress * duration_seconds)
791 .position
792 }
793 })
794}
795
796fn envelope_decay_start(constant: f32, linear: f32, decay: f32) -> f32 {
797 if linear == 0.0 {
798 0.0
799 } else {
800 (1.0 / decay - constant / linear).max(0.0)
801 }
802}
803
804fn find_settle_time(
805 position_threshold: f32,
806 velocity_threshold: f32,
807 decay_start: f32,
808 natural_frequency: f32,
809 envelope: impl Fn(f32) -> (f32, f32),
810) -> Duration {
811 let is_below_threshold = |time| {
812 let (position, velocity) = envelope(time);
813 position <= position_threshold && velocity <= velocity_threshold
814 };
815
816 if is_below_threshold(decay_start) {
817 return duration_from_secs(decay_start);
818 }
819
820 let mut lower_bound = decay_start;
821 let mut upper_bound = decay_start.max(natural_frequency.recip());
822 while !is_below_threshold(upper_bound) {
823 lower_bound = upper_bound;
824 upper_bound *= 2.0;
825 if !upper_bound.is_finite() {
826 return Duration::MAX;
827 }
828 }
829
830 for _ in 0..32 {
831 let midpoint = (lower_bound + upper_bound) / 2.0;
832 if is_below_threshold(midpoint) {
833 upper_bound = midpoint;
834 } else {
835 lower_bound = midpoint;
836 }
837 }
838
839 duration_from_secs(upper_bound)
840}
841
842fn duration_from_secs(seconds: f32) -> Duration {
843 if !seconds.is_finite() || seconds >= Duration::MAX.as_secs_f32() {
844 Duration::MAX
845 } else if seconds <= 0.0 {
846 Duration::ZERO
847 } else {
848 Duration::from_secs_f32(seconds)
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 const EPSILON: f32 = 1e-4;
857
858 #[test]
859 fn spring_targets_resolve_typed_outputs() {
860 assert_eq!(12.0_f32.target(), 12.0);
861 assert_eq!(12.0_f32.resolve(14.0), 14.0);
862 assert_eq!(Pixels::from(12.0).target(), 12.0);
863 assert_eq!(Pixels::from(12.0).resolve(14.0), Pixels::from(14.0));
864 assert_eq!(false.target(), 0.0);
865 assert_eq!(true.target(), 1.0);
866 assert_eq!(true.resolve(1.25), AnimationPhase(1.25));
867 }
868
869 #[test]
870 fn animation_phases_interpolate_over_arbitrary_ranges() {
871 let phase = AnimationPhase(1.5);
872 assert_eq!(phase.interpolate_between(1.0..=2.0, 10.0, 20.0), 15.0);
873 assert_eq!(
874 phase.interpolate_between_clamped(2.0..=3.0, 10.0, 20.0),
875 10.0
876 );
877 assert_eq!(
878 AnimationPhase(3.5).interpolate_between(2.0..=3.0, 10.0, 20.0),
879 25.0
880 );
881 }
882
883 #[test]
884 fn hsla_interpolation_takes_the_shortest_hue_path() {
885 let from = crate::hsla(0.9, 0.5, 0.5, 1.0);
886 let to = crate::hsla(0.1, 1.0, 0.75, 0.5);
887 let result = AnimationPhase(0.5).interpolate(from, to);
888
889 let h = result.color.hue.into_degrees() / 360.0;
890 assert!(h < EPSILON || (1.0 - h) < EPSILON);
891 assert!((result.color.saturation - 0.75).abs() < EPSILON);
892 assert!((result.color.lightness - 0.625).abs() < EPSILON);
893 assert!((result.alpha - 0.75).abs() < EPSILON);
894 }
895
896 #[test]
897 fn propagators_compose_and_have_expected_determinant() {
898 for damping_ratio in [0.4, 1.0, 1.5] {
899 let natural_frequency = 12.0;
900 let config = SpringConfig::new(
901 natural_frequency * natural_frequency,
902 2.0 * damping_ratio * natural_frequency,
903 1.0,
904 );
905 let first = config.propagator(0.013);
906 let second = config.propagator(0.021);
907 let combined = multiply(second, first);
908 let direct = config.propagator(0.034);
909
910 for row in 0..2 {
911 for column in 0..2 {
912 assert!(
913 (combined[row][column] - direct[row][column]).abs() < 2e-4,
914 "{damping_ratio}: {combined:?} != {direct:?}"
915 );
916 }
917 }
918
919 let determinant = direct[0][0] * direct[1][1] - direct[0][1] * direct[1][0];
920 let expected = (-2.0 * damping_ratio * natural_frequency * 0.034).exp();
921 assert!((determinant - expected).abs() < 2e-4);
922 }
923 }
924
925 #[test]
926 fn step_preserves_semigroup_for_every_damping_regime() {
927 let state = SpringState {
928 position: -3.0,
929 velocity: 5.0,
930 };
931 for damping in [4.0, 20.0, 40.0] {
932 let config = SpringConfig::new(100.0, damping, 1.0);
933 let stepped = config.step(config.step(state, 7.0, 0.013), 7.0, 0.021);
934 let direct = config.step(state, 7.0, 0.034);
935
936 assert!((stepped.position - direct.position).abs() < 2e-4);
937 assert!((stepped.velocity - direct.velocity).abs() < 2e-4);
938 }
939 }
940
941 #[test]
942 fn ramp_tracks_steady_state_lag() {
943 let natural_frequency = 10.0;
944 let damping_ratio = 0.8;
945 let target_velocity = 3.0;
946 let config = SpringConfig::new(
947 natural_frequency * natural_frequency,
948 2.0 * damping_ratio * natural_frequency,
949 1.0,
950 );
951 let lag = -2.0 * damping_ratio * target_velocity / natural_frequency;
952 let state = SpringState {
953 position: lag,
954 velocity: target_velocity,
955 };
956 let next = config.step_ramp(state, 0.0, target_velocity, 0.25);
957
958 assert!((next.position - (target_velocity * 0.25 + lag)).abs() < EPSILON);
959 assert!((next.velocity - target_velocity).abs() < EPSILON);
960 }
961
962 #[test]
963 fn settling_requires_low_velocity() {
964 let config = SpringConfig::new(100.0, 10.0, 1.0);
965 assert!(!config.is_settled(
966 SpringState {
967 position: 1.0,
968 velocity: 1.0,
969 },
970 1.0,
971 0.01,
972 ));
973 assert!(config.is_settled(
974 SpringState {
975 position: 1.005,
976 velocity: 0.05,
977 },
978 1.0,
979 0.01,
980 ));
981 }
982
983 #[test]
984 fn settle_time_is_conservative_for_every_damping_regime() {
985 let initial_state = SpringState {
986 position: -2.0,
987 velocity: 4.0,
988 };
989 for damping in [4.0, 20.0, 40.0] {
990 let config = SpringConfig::new(100.0, damping, 1.0);
991 let duration = config.settle_time(initial_state, 3.0, 0.001);
992 assert_ne!(duration, Duration::MAX);
993
994 for additional_time in [0.0, 0.1, 1.0] {
995 let state =
996 config.step(initial_state, 3.0, duration.as_secs_f32() + additional_time);
997 assert!(
998 config.is_settled(state, 3.0, 0.001),
999 "{damping}: {duration:?} produced {state:?}"
1000 );
1001 }
1002 }
1003 }
1004
1005 #[test]
1006 fn settle_time_accounts_for_motion_outside_an_instantaneous_tolerance() {
1007 let config = SpringConfig::new(100.0, 2.0, 1.0);
1008 let state = SpringState {
1009 position: 1.125,
1010 velocity: 1.25,
1011 };
1012 assert!(config.is_settled(state, 1.0, 0.125));
1013
1014 let duration = config.settle_time(state, 1.0, 0.125);
1015 assert!(duration > Duration::ZERO);
1016 assert!(config.is_settled(config.step(state, 1.0, duration.as_secs_f32()), 1.0, 0.125,));
1017 }
1018
1019 #[test]
1020 fn undamped_spring_never_settles() {
1021 let config = SpringConfig::new(100.0, 0.0, 1.0);
1022 assert_eq!(
1023 config.settle_time(
1024 SpringState {
1025 position: 0.0,
1026 velocity: 0.0,
1027 },
1028 1.0,
1029 0.001,
1030 ),
1031 Duration::MAX
1032 );
1033 }
1034
1035 #[test]
1036 fn sampled_easing_has_exact_endpoints_and_can_overshoot() {
1037 let config = SpringConfig::new(100.0, 6.0, 1.0);
1038 let (duration, easing) = sampled_easing(config, 0.001);
1039
1040 assert_ne!(duration, Duration::MAX);
1041 assert_eq!(easing(0.0), 0.0);
1042 assert_eq!(easing(1.0), 1.0);
1043 assert!((1..100).any(|step| easing(step as f32 / 100.0) > 1.0));
1044 }
1045
1046 fn multiply(left: [[f32; 2]; 2], right: [[f32; 2]; 2]) -> [[f32; 2]; 2] {
1047 [
1048 [
1049 left[0][0] * right[0][0] + left[0][1] * right[1][0],
1050 left[0][0] * right[0][1] + left[0][1] * right[1][1],
1051 ],
1052 [
1053 left[1][0] * right[0][0] + left[1][1] * right[1][0],
1054 left[1][0] * right[0][1] + left[1][1] * right[1][1],
1055 ],
1056 ]
1057 }
1058}