onedrop_engine/engine/
morph.rs

1//! Smart boolean interpolation of motion params across a preset
2//! transition.
3//!
4//! Once both slots' per-frame equations have written fresh values into
5//! their respective evaluator contexts, this module pulls the motion
6//! params (`zoom` / `rot` / `cx` / `cy` / `sx` / `sy` / `dx` / `dy` /
7//! `warp`) out of both contexts, lerps them linearly by the raw
8//! transition progress, and pushes the **same** lerped values back into
9//! both contexts. The downstream warp executor then sees an identical
10//! starting state on both chains — what visually feels like a soft tug
11//! between the two presets' compositions.
12//!
13//! Boolean flags (`wrap`, `darken_center`, `invert`, `brighten`, `darken`,
14//! `solarize`) don't lerp meaningfully — they snap from the outgoing
15//! preset's value to the incoming one's at 50 % progress.
16//!
17//! This is what MilkDrop calls "smart boolean interpolation": floats
18//! morph, bools switch.
19
20use super::preset_slot::PresetSlot;
21
22/// Continuous motion params that lerp during a transition. Order is
23/// load-bearing — [`snapshot_motion`] and [`write_motion`] index into the
24/// returned array by position.
25const MOTION_VARS: &[&str] = &["zoom", "rot", "cx", "cy", "sx", "sy", "dx", "dy", "warp"];
26
27/// Default fallback per motion var when neither preset wrote it. Matches
28/// the defaults that [`super::state_sync::sync_render_state`] applies when
29/// reading the same vars out to `RenderState.motion`.
30const MOTION_DEFAULTS: &[f64] = &[
31    /* zoom */ 1.0, /* rot */ 0.0, /* cx */ 0.5, /* cy */ 0.5,
32    /* sx */ 1.0, /* sy */ 1.0, /* dx */ 0.0, /* dy */ 0.0,
33    /* warp */ 0.0,
34];
35
36/// Bool-ish flags that snap at the 50 % midpoint instead of lerping.
37const BOOL_FLAGS: &[&str] = &[
38    "wrap",
39    "darken_center",
40    "invert",
41    "brighten",
42    "darken",
43    "solarize",
44];
45
46fn snapshot_motion(slot: &PresetSlot) -> [f64; 9] {
47    let ctx = slot.evaluator.context();
48    let mut out = [0.0; 9];
49    for (i, name) in MOTION_VARS.iter().enumerate() {
50        out[i] = ctx.get_var(name).unwrap_or(MOTION_DEFAULTS[i]);
51    }
52    out
53}
54
55fn snapshot_flags(slot: &PresetSlot) -> [f64; 6] {
56    let ctx = slot.evaluator.context();
57    let mut out = [0.0; 6];
58    for (i, name) in BOOL_FLAGS.iter().enumerate() {
59        out[i] = ctx.get_var(name).unwrap_or(0.0);
60    }
61    out
62}
63
64fn write_motion_and_flags(slot: &mut PresetSlot, motion: &[f64; 9], flags: &[f64; 6]) {
65    let ctx = slot.evaluator.context_mut();
66    for (i, name) in MOTION_VARS.iter().enumerate() {
67        ctx.set_var(name, motion[i]);
68    }
69    for (i, name) in BOOL_FLAGS.iter().enumerate() {
70        ctx.set_var(name, flags[i]);
71    }
72}
73
74/// Lerp the motion params between the outgoing preset's slot and the
75/// incoming one's, then push the result into **both** evaluator contexts.
76///
77/// `progress` is the raw `[0, 1]` linear value — passing the eased value
78/// would distort the morph asymmetrically against the alpha crossfade.
79/// At `progress = 0` both contexts end up with the outgoing preset's
80/// motion; at `progress = 1` with the incoming one's; in between, the
81/// linear midpoint.
82///
83/// Boolean flags snap to the incoming preset's value at `progress >= 0.5`,
84/// otherwise to the outgoing preset's.
85pub(super) fn apply_smart_boolean_interpolation(
86    outgoing: &mut PresetSlot,
87    incoming: &mut PresetSlot,
88    progress: f32,
89) {
90    let t = progress.clamp(0.0, 1.0) as f64;
91
92    let a = snapshot_motion(outgoing);
93    let b = snapshot_motion(incoming);
94    let lerped: [f64; 9] = std::array::from_fn(|i| a[i] * (1.0 - t) + b[i] * t);
95
96    let flags = if progress < 0.5 {
97        snapshot_flags(outgoing)
98    } else {
99        snapshot_flags(incoming)
100    };
101
102    write_motion_and_flags(outgoing, &lerped, &flags);
103    write_motion_and_flags(incoming, &lerped, &flags);
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use onedrop_eval::MilkEvaluator;
110
111    fn slot_with(motion: &[(&str, f64)], flags: &[(&str, f64)]) -> PresetSlot {
112        let mut slot = PresetSlot::default();
113        let ctx = slot.evaluator.context_mut();
114        for (name, value) in motion {
115            ctx.set_var(name, *value);
116        }
117        for (name, value) in flags {
118            ctx.set_var(name, *value);
119        }
120        slot
121    }
122
123    fn read(slot: &PresetSlot, name: &str) -> f64 {
124        slot.evaluator.context().get_var(name).unwrap_or(0.0)
125    }
126
127    #[test]
128    fn progress_zero_keeps_outgoing_motion() {
129        let mut a = slot_with(&[("zoom", 2.0), ("rot", 0.5), ("cx", 0.2)], &[]);
130        let mut b = slot_with(&[("zoom", 1.0), ("rot", 0.0), ("cx", 0.5)], &[]);
131        apply_smart_boolean_interpolation(&mut a, &mut b, 0.0);
132        assert!((read(&a, "zoom") - 2.0).abs() < 1e-9);
133        assert!(
134            (read(&b, "zoom") - 2.0).abs() < 1e-9,
135            "both lerped to outgoing"
136        );
137        assert!((read(&b, "cx") - 0.2).abs() < 1e-9);
138    }
139
140    #[test]
141    fn progress_one_keeps_incoming_motion() {
142        let mut a = slot_with(&[("zoom", 2.0)], &[]);
143        let mut b = slot_with(&[("zoom", 1.0)], &[]);
144        apply_smart_boolean_interpolation(&mut a, &mut b, 1.0);
145        assert!((read(&a, "zoom") - 1.0).abs() < 1e-9);
146        assert!((read(&b, "zoom") - 1.0).abs() < 1e-9);
147    }
148
149    #[test]
150    fn progress_half_lerps_midpoint() {
151        let mut a = slot_with(&[("zoom", 0.0), ("dx", 1.0)], &[]);
152        let mut b = slot_with(&[("zoom", 4.0), ("dx", 0.0)], &[]);
153        apply_smart_boolean_interpolation(&mut a, &mut b, 0.5);
154        assert!((read(&a, "zoom") - 2.0).abs() < 1e-9);
155        assert!((read(&b, "zoom") - 2.0).abs() < 1e-9);
156        assert!((read(&a, "dx") - 0.5).abs() < 1e-9);
157        assert!((read(&b, "dx") - 0.5).abs() < 1e-9);
158    }
159
160    #[test]
161    fn bool_flags_snap_at_midpoint() {
162        let mut a = slot_with(&[], &[("wrap", 1.0), ("darken_center", 0.0)]);
163        let mut b = slot_with(&[], &[("wrap", 0.0), ("darken_center", 1.0)]);
164
165        // Just below 50 % → outgoing wins.
166        apply_smart_boolean_interpolation(&mut a, &mut b, 0.49);
167        assert!((read(&a, "wrap") - 1.0).abs() < 1e-9);
168        assert!((read(&a, "darken_center") - 0.0).abs() < 1e-9);
169        assert!((read(&b, "wrap") - 1.0).abs() < 1e-9);
170
171        // Re-seed and test the other side of the boundary.
172        let mut a = slot_with(&[], &[("wrap", 1.0), ("darken_center", 0.0)]);
173        let mut b = slot_with(&[], &[("wrap", 0.0), ("darken_center", 1.0)]);
174        apply_smart_boolean_interpolation(&mut a, &mut b, 0.5);
175        assert!((read(&a, "wrap") - 0.0).abs() < 1e-9);
176        assert!((read(&a, "darken_center") - 1.0).abs() < 1e-9);
177        assert!((read(&b, "wrap") - 0.0).abs() < 1e-9);
178    }
179
180    #[test]
181    fn missing_vars_fall_back_to_md2_defaults() {
182        // Outgoing seeds nothing → defaults pulled in.
183        let mut a = PresetSlot::default();
184        let mut b = slot_with(&[("zoom", 5.0)], &[]);
185        apply_smart_boolean_interpolation(&mut a, &mut b, 1.0);
186        assert!((read(&a, "zoom") - 5.0).abs() < 1e-9);
187
188        // progress=0 with no outgoing zoom → defaults to 1.0 (the MD2
189        // unit zoom), so we lerp(1.0, 5.0, 0) = 1.0
190        let mut a = PresetSlot::default();
191        let mut b = slot_with(&[("zoom", 5.0)], &[]);
192        apply_smart_boolean_interpolation(&mut a, &mut b, 0.0);
193        assert!((read(&a, "zoom") - 1.0).abs() < 1e-9);
194    }
195
196    // Touch MilkEvaluator so it doesn't get flagged unused in this test
197    // module — used implicitly via PresetSlot::default().
198    #[allow(dead_code)]
199    fn _force_use_evaluator() -> MilkEvaluator {
200        MilkEvaluator::new()
201    }
202}