Skip to main content

bevy_react/filters/builtin/
outline.rs

1//! The `outline` built-in: an alpha-dilation outline around the content.
2
3use std::sync::Arc;
4
5use bevy::asset::load_embedded_asset;
6use bevy::prelude::*;
7use bevy::shader::Shader;
8use serde::Deserialize;
9use serde_json::Value;
10
11use crate::animations::ValueKind;
12use crate::filters::params::{FilterColor, ParamSlot, length_logical_px, static_layout};
13use crate::filters::registry::{ReactFilter, ResolvedFilterPass, resolve_single_pass};
14use crate::protocol::units::Length;
15
16fn default_width() -> Length {
17    Length::Px(2.0)
18}
19
20fn default_outline_color() -> FilterColor {
21    // Opaque black (linear == sRGB at the extremes).
22    FilterColor([0.0, 0.0, 0.0, 1.0])
23}
24
25/// `outline`: paint a `color` ring of `width` px around the content's alpha
26/// silhouette, UNDER the content (source-over) — text outlines (wrap the
27/// `<text>` in a `<node>`), sticker-style icon rings. `softness` feathers
28/// the ring's outer edge over that many extra px, doubling as a glow. The
29/// outline follows whatever the chain has produced so far: `[gradientMap,
30/// outline]` outlines the recolored glyphs, `[blur, outline]` the blurred
31/// silhouette. `{ name: "outline" }` is a crisp 2px black outline
32/// (shorthand-default convention); the true identity is `width: 0,
33/// softness: 0`.
34///
35/// One pass, packed as:
36///
37/// ```text
38/// params[0] = (width_logical_px, softness_logical_px, 0, 0)
39/// params[1] = color, linear straight-alpha RGBA
40/// ```
41///
42/// All three params take `{ animated }` bindings (`width`/`softness` scalar
43/// Length slots, `color` via `interpolateColor`). Quality bound (see
44/// `outline.wgsl`): the dilation is crisp up to a reach of ~12 physical px;
45/// practical text outlines are 1–6 logical px.
46#[derive(Debug, Clone, Copy, PartialEq, Deserialize, ts_rs::TS)]
47#[serde(deny_unknown_fields)]
48pub struct OutlineParams {
49    // Mirrors `#[react_filter]`'s override for a `Length` field.
50    #[serde(default = "default_width")]
51    #[ts(type = "number | string")]
52    pub width: Length,
53    #[serde(default = "default_outline_color")]
54    pub color: FilterColor,
55    #[serde(default)]
56    #[ts(type = "number | string")]
57    pub softness: Length,
58}
59
60impl Default for OutlineParams {
61    fn default() -> Self {
62        Self {
63            width: default_width(),
64            color: default_outline_color(),
65            softness: Length::Px(0.0),
66        }
67    }
68}
69
70fn outline_layout() -> Arc<[ParamSlot]> {
71    static_layout![
72        ParamSlot {
73            name: "width",
74            kind: ValueKind::Length,
75            vec: 0,
76            comp: 0,
77            len: 1,
78        },
79        ParamSlot {
80            name: "softness",
81            kind: ValueKind::Length,
82            vec: 0,
83            comp: 1,
84            len: 1,
85        },
86        ParamSlot {
87            name: "color",
88            kind: ValueKind::Color,
89            vec: 1,
90            comp: 0,
91            len: 4,
92        },
93    ]
94}
95
96impl OutlineParams {
97    fn width_px(&self) -> Result<f32, String> {
98        length_logical_px(Self::NAME, "width", self.width)
99    }
100
101    fn softness_px(&self) -> Result<f32, String> {
102        length_logical_px(Self::NAME, "softness", self.softness)
103    }
104}
105
106impl ReactFilter for OutlineParams {
107    const NAME: &'static str = "outline";
108
109    fn shader(assets: &AssetServer) -> Handle<Shader> {
110        load_embedded_asset!(assets, "outline.wgsl")
111    }
112
113    fn identity_params() -> Option<Value> {
114        Some(serde_json::json!({ "width": 0.0, "softness": 0.0 }))
115    }
116
117    /// The ring reaches `width + softness` past the silhouette, plus 1
118    /// logical px of antialiasing skirt (the shader's hard-edge feather).
119    fn outset(&self) -> Result<f32, String> {
120        Ok(self.width_px()? + self.softness_px()? + 1.0)
121    }
122
123    fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>) {
124        // `pack` is infallible, so non-px lengths fall back to 0 here — but
125        // they can never reach the shader: `resolve`/`outset` reject first.
126        (
127            vec![
128                Vec4::new(
129                    self.width_px().unwrap_or(0.0),
130                    self.softness_px().unwrap_or(0.0),
131                    0.0,
132                    0.0,
133                ),
134                Vec4::from_array(self.color.0),
135            ],
136            outline_layout(),
137        )
138    }
139
140    fn resolve(&self, assets: &AssetServer) -> Result<Vec<ResolvedFilterPass>, String> {
141        self.width_px()?;
142        self.softness_px()?;
143        resolve_single_pass(self, assets)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use serde_json::json;
150
151    use super::*;
152    use crate::filters::registry::FilterRegistry;
153    use crate::filters::test_util::{asset_app, params};
154
155    /// One pass: width/softness logical px in `params[0].xy` (Length slots
156    /// for the physical rewrite), color in `params[1]` — default opaque
157    /// black.
158    #[test]
159    fn packs_width_softness_color() {
160        let (vecs, layout) = params::<OutlineParams>(json!({
161            "width": 3, "softness": 2, "color": "#ff0000",
162        }))
163        .pack();
164        assert_eq!(vecs.len(), 2);
165        assert_eq!(vecs[0], Vec4::new(3.0, 2.0, 0.0, 0.0));
166        assert_eq!(vecs[1], Vec4::new(1.0, 0.0, 0.0, 1.0));
167        assert_eq!(layout[0].name, "width");
168        assert_eq!(layout[0].kind, ValueKind::Length);
169        assert_eq!(layout[1].name, "softness");
170        assert_eq!(layout[1].kind, ValueKind::Length);
171        assert_eq!(layout[1].comp, 1);
172        assert_eq!(layout[2].name, "color");
173        assert_eq!(layout[2].kind, ValueKind::Color);
174        assert_eq!(layout[2].vec, 1);
175
176        let (vecs, _) = params::<OutlineParams>(json!({})).pack();
177        assert_eq!(vecs[1], Vec4::new(0.0, 0.0, 0.0, 1.0), "default black");
178    }
179
180    /// The ring bleeds `width + softness` past the silhouette plus the 1px
181    /// AA skirt — the identity params still carry the skirt (harmless: the
182    /// outset only sizes the capture).
183    #[test]
184    fn outset_is_width_plus_softness_plus_aa() {
185        assert_eq!(
186            params::<OutlineParams>(json!({ "width": 4, "softness": 2 })).outset(),
187            Ok(7.0)
188        );
189        assert_eq!(params::<OutlineParams>(json!({})).outset(), Ok(3.0));
190        let identity = OutlineParams::identity_params().expect("has identity");
191        let p: OutlineParams = serde_json::from_value(identity).expect("identity decodes");
192        assert_eq!(p.outset(), Ok(1.0));
193        assert_eq!(p.width, Length::Px(0.0));
194        assert_eq!(p.softness, Length::Px(0.0));
195    }
196
197    /// Empty params take the shorthand defaults: a crisp 2px black outline.
198    #[test]
199    fn empty_params_default_to_crisp_black_outline() {
200        let p = params::<OutlineParams>(json!({}));
201        assert_eq!(p, OutlineParams::default());
202        assert_eq!(p.width, Length::Px(2.0));
203        assert_eq!(p.softness, Length::Px(0.0));
204    }
205
206    /// Non-px width/softness reject from both baked registry fns, naming the
207    /// unit — same contract as blur's radius.
208    #[test]
209    fn non_px_width_and_softness_reject_from_registry() {
210        let app = asset_app();
211        let assets = app.world().resource::<AssetServer>();
212        let mut registry = FilterRegistry::default();
213        registry.register::<OutlineParams>();
214        let entry = &registry.entries["outline"];
215
216        for value in [json!({ "width": "50%" }), json!({ "softness": "2vw" })] {
217            let err = (entry.resolve)(&value, assets).expect_err("non-px must reject resolve");
218            assert!(err.contains("px"), "names the unit: {err}");
219            let err = (entry.outset)(&value).expect_err("non-px must reject outset");
220            assert!(err.contains("px"), "names the unit: {err}");
221        }
222        assert!((entry.resolve)(&json!({ "width": "3px" }), assets).is_ok());
223        assert_eq!((entry.outset)(&json!({ "width": 3 })), Ok(4.0));
224    }
225
226    /// One pass, wire index 0.
227    #[test]
228    fn resolves_to_one_pass() {
229        let app = asset_app();
230        let assets = app.world().resource::<AssetServer>();
231        let passes = params::<OutlineParams>(json!({}))
232            .resolve(assets)
233            .expect("outline resolves");
234        assert_eq!(passes.len(), 1);
235        assert_eq!(passes[0].wire_index, 0);
236    }
237
238    /// `deny_unknown_fields`: a typoed param rejects.
239    #[test]
240    fn unknown_outline_param_rejects() {
241        assert!(serde_json::from_value::<OutlineParams>(json!({ "widht": 2 })).is_err());
242    }
243}