bevy_react/filters/builtin/
outline.rs1use 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 FilterColor([0.0, 0.0, 0.0, 1.0])
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Deserialize, ts_rs::TS)]
47#[serde(deny_unknown_fields)]
48pub struct OutlineParams {
49 #[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 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 (
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 #[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 #[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 #[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 #[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 = ®istry.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 #[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 #[test]
240 fn unknown_outline_param_rejects() {
241 assert!(serde_json::from_value::<OutlineParams>(json!({ "widht": 2 })).is_err());
242 }
243}