Skip to main content

motion_core/authoring/
elevation.rs

1//! Height-driven layered soft shadows built from existing filter primitives.
2
3use crate::{Color, Filter, FilterOperation, Node, NodeModifiers, ValidationError, Vector2};
4
5/// One soft shadow silhouette: blur sigma, offset and opacity multiplier.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct ShadowLayer {
8    /// Gaussian standard deviation in local units.
9    pub sigma: f64,
10    /// Local displacement of the silhouette.
11    pub offset: Vector2,
12    /// Multiplier on the shadow color's alpha, in 0..=1.
13    pub opacity: f64,
14}
15
16/// Height above the surface, expanded into contact, key and ambient shadows.
17///
18/// A small dark contact shadow anchors the object, a medium key shadow follows the
19/// light direction and a wide faint ambient shadow suggests occlusion. Sigmas and
20/// offsets grow linearly with `height`, while opacity falls off smoothly.
21///
22/// ```
23/// use motion_core::{Color, Elevation, NodeModifiers, Rect, Shape};
24/// let card = Rect::new(0.0, 0.0, 80.0, 50.0).fill(Color::WHITE);
25/// let raised = card.elevated(Elevation::shadow(12.0)?);
26/// # let _ = raised;
27/// # Ok::<(), motion_core::ValidationError>(())
28/// ```
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct Elevation {
31    height: f64,
32    direction: Vector2,
33    color: Color,
34}
35
36impl Elevation {
37    /// Largest accepted height in local units.
38    pub const MAX_HEIGHT: f64 = 1024.0;
39
40    /// Creates a downward-falling black shadow stack for a finite height in `0..=1024`.
41    pub fn shadow(height: f64) -> Result<Self, ValidationError> {
42        if !height.is_finite() || !(0.0..=Self::MAX_HEIGHT).contains(&height) {
43            return Err(ValidationError::invalid(
44                "elevation",
45                "height",
46                "Require a finite height in 0..=1024",
47            ));
48        }
49        Ok(Self {
50            height,
51            direction: Vector2::new(0.0, 1.0),
52            color: Color::BLACK,
53        })
54    }
55
56    /// Sets the direction the shadow falls; the vector is normalized.
57    pub fn direction(mut self, direction: Vector2) -> Result<Self, ValidationError> {
58        let length = libm::hypot(direction.x, direction.y);
59        if !length.is_finite() || length <= 0.0 {
60            return Err(ValidationError::invalid(
61                "elevation",
62                "direction",
63                "Require a finite nonzero direction",
64            ));
65        }
66        self.direction = Vector2::new(direction.x / length, direction.y / length);
67        Ok(self)
68    }
69
70    /// Sets the encoded-sRGB shadow color; its alpha scales every layer.
71    pub fn color(mut self, color: Color) -> Self {
72        self.color = color;
73        self
74    }
75
76    /// The elevation in local units.
77    pub fn height(self) -> f64 {
78        self.height
79    }
80
81    /// Contact, key and ambient layers; empty at zero height.
82    pub fn layers(self) -> Vec<ShadowLayer> {
83        let height = self.height;
84        if height == 0.0 {
85            return Vec::new();
86        }
87        // Opacity fades as the object lifts away, but never vanishes within the bounded range.
88        let fade = 1.0 / (1.0 + height / 64.0);
89        [
90            (0.1 * height + 0.5, 0.06 * height, 0.55 * fade),
91            (0.5 * height, 0.4 * height, 0.32 * fade),
92            (1.0 * height + 1.0, 0.8 * height, 0.2 * fade),
93        ]
94        .map(|(sigma, distance, opacity)| ShadowLayer {
95            sigma,
96            offset: Vector2::new(self.direction.x * distance, self.direction.y * distance),
97            opacity,
98        })
99        .into()
100    }
101
102    /// Draws the layers behind a single untouched source snapshot.
103    pub fn apply(self, source: Node) -> Node {
104        let layers = self.layers();
105        if layers.is_empty() {
106            return source;
107        }
108        shadow_stack(
109            source,
110            self.color,
111            layers
112                .into_iter()
113                .map(|layer| (layer.sigma, layer.offset, layer.opacity)),
114        )
115    }
116}
117
118/// Colorizes the source alpha, blurs and offsets one clone per layer, then draws the source.
119pub(super) fn shadow_stack(
120    source: Node,
121    color: Color,
122    layers: impl IntoIterator<Item = (f64, Vector2, f64)>,
123) -> Node {
124    let linear = color.to_linear_premultiplied();
125    let straight = if linear[3] > 0.0 {
126        [
127            linear[0] / linear[3],
128            linear[1] / linear[3],
129            linear[2] / linear[3],
130        ]
131    } else {
132        [0.0; 3]
133    };
134    let mut nodes = Vec::new();
135    for (sigma, offset, opacity) in layers {
136        let mut matrix = [0.0; 20];
137        matrix[4] = straight[0];
138        matrix[9] = straight[1];
139        matrix[14] = straight[2];
140        matrix[18] = linear[3] * opacity;
141        nodes.push(source.clone().filter(Filter::Custom(vec![
142            FilterOperation::ColorMatrix(matrix),
143            FilterOperation::Blur { sigma },
144            FilterOperation::Offset {
145                x: offset.x,
146                y: offset.y,
147            },
148        ])));
149    }
150    nodes.push(source);
151    Node::group(nodes)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::Rect;
158    use crate::Shape;
159
160    #[test]
161    fn layers_grow_with_height_and_fade_in_opacity() {
162        assert!(Elevation::shadow(0.0).unwrap().layers().is_empty());
163        let low = Elevation::shadow(4.0).unwrap().layers();
164        let high = Elevation::shadow(40.0).unwrap().layers();
165        assert_eq!(low.len(), 3);
166        for (low, high) in low.iter().zip(&high) {
167            assert!(high.sigma > low.sigma);
168            assert!(high.offset.y > low.offset.y);
169            assert!(high.opacity < low.opacity);
170        }
171        // Contact is tight and dark, ambient is wide and faint.
172        assert!(low[0].sigma < low[2].sigma && low[0].opacity > low[2].opacity);
173    }
174
175    #[test]
176    fn direction_is_normalized_and_inputs_are_validated() {
177        let layers = Elevation::shadow(10.0)
178            .unwrap()
179            .direction(Vector2::new(3.0, 4.0))
180            .unwrap()
181            .layers();
182        let ratio = layers[1].offset.x / layers[1].offset.y;
183        assert!((ratio - 0.75).abs() < 1e-12);
184        for height in [-1.0, f64::NAN, f64::INFINITY, 2000.0] {
185            assert!(Elevation::shadow(height).is_err());
186        }
187        let elevation = Elevation::shadow(1.0).unwrap();
188        assert!(elevation.direction(Vector2::new(0.0, 0.0)).is_err());
189        assert!(elevation.direction(Vector2::new(f64::NAN, 1.0)).is_err());
190    }
191
192    #[test]
193    fn apply_builds_layer_clones_before_the_untouched_source() {
194        let card: Node = Rect::new(0.0, 0.0, 8.0, 8.0).fill(Color::WHITE).into();
195        let raised = card.clone().elevated(Elevation::shadow(8.0).unwrap());
196        let children = |node: &Node| match &node.data.kind {
197            super::super::NodeKind::Group(children) => Some(children.len()),
198            _ => None,
199        };
200        assert_eq!(children(&raised), Some(4));
201        let flat = card.elevated(Elevation::shadow(0.0).unwrap());
202        assert_eq!(children(&flat), None);
203    }
204}