diff --git a/frontend/src/components/floating-menus/NodeCatalog.svelte b/frontend/src/components/floating-menus/NodeCatalog.svelte index 08d287d4b7..82b66aa592 100644 --- a/frontend/src/components/floating-menus/NodeCatalog.svelte +++ b/frontend/src/components/floating-menus/NodeCatalog.svelte @@ -113,7 +113,7 @@ - (searchTerm = detail)} bind:this={nodeSearchInput} /> + (searchTerm = detail)} selectAllOnFocus={false} bind:this={nodeSearchInput} />
{#each nodeCategories as nodeCategory}
diff --git a/frontend/src/components/widgets/inputs/TextInput.svelte b/frontend/src/components/widgets/inputs/TextInput.svelte index 613e21f2e4..a1cca00981 100644 --- a/frontend/src/components/widgets/inputs/TextInput.svelte +++ b/frontend/src/components/widgets/inputs/TextInput.svelte @@ -16,10 +16,12 @@ // Sizing export let minWidth = 0; export let maxWidth = 0; - // Tooltips + // Tooltips export let tooltipLabel: string | undefined = undefined; export let tooltipDescription: string | undefined = undefined; export let tooltipShortcut: ActionShortcut | undefined = undefined; + // Behavior + export let selectAllOnFocus = true; let className = ""; export { className as class }; @@ -28,10 +30,10 @@ let self: FieldInput | undefined; let editing = false; - function onTextFocused() { + function onTextFocused() { editing = true; - self?.selectAllText(value); + if (selectAllOnFocus) self?.selectAllText(value); } // Called only when `value` is changed from the element via user input and committed, either with the diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index b0d5888115..41cbf935d5 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -14,6 +14,8 @@ use std::fmt::Debug; pub const ATTR_TRANSFORM: &str = "transform"; /// Item's `BlendMode`, controlling how it composites with content beneath it. pub const ATTR_BLEND_MODE: &str = "blend_mode"; +/// Item's SVG-compatible filter effects, applied in order during rendering. +pub const ATTR_FILTER_EFFECTS: &str = "filter_effects"; /// Item's opacity multiplier (`f64`, implicit default `1.`). /// Composed multiplicatively through nested groups. Affects content clipped to the item. pub const ATTR_OPACITY: &str = "opacity"; diff --git a/node-graph/libraries/graphic-types/src/filter.rs b/node-graph/libraries/graphic-types/src/filter.rs new file mode 100644 index 0000000000..d631fd4687 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/filter.rs @@ -0,0 +1,17 @@ +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, dyn_any::DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum SvgFilterEffect { + GaussianBlur { + std_deviation_x: f64, + std_deviation_y: f64, + }, +} + +impl Default for SvgFilterEffect { + fn default() -> Self { + Self::GaussianBlur { + std_deviation_x: 0., + std_deviation_y: 0., + } + } +} diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index 24f40b88e4..be9a0e209a 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -1,10 +1,12 @@ pub mod artboard; pub mod graphic; +pub mod filter; // Re-export all transitive dependencies so downstream crates only need to depend on graphic-types pub use core_types; pub use raster_types; pub use vector_types; +pub use filter::SvgFilterEffect; // Re-export commonly used types at the crate root pub use artboard::Artboard; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 74c2405ae0..e0b710a05f 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -7,7 +7,7 @@ use core_types::bounds::RenderBoundingBox; use core_types::color::Color; use core_types::color::SRGBA8; use core_types::consts::DEFAULT_FONT_SIZE; -use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List}; +use core_types::list::{ATTR_FILL, ATTR_FILTER_EFFECTS, ATTR_STROKE, Item, List}; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; use core_types::transform::Footprint; @@ -21,6 +21,7 @@ use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; +use graphic_types::SvgFilterEffect; use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; @@ -268,6 +269,29 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } +fn write_svg_filter_def(svg_defs: &mut String, effects: &[SvgFilterEffect]) -> Option { + if effects.is_empty() { + return None; + } + + let id = format!("filter-{}", generate_uuid()); + write!(svg_defs, r#""#).unwrap(); + + let mut input = "SourceGraphic".to_string(); + for (index, effect) in effects.iter().enumerate() { + let result = format!("effect{index}"); + match effect { + SvgFilterEffect::GaussianBlur { std_deviation_x, std_deviation_y } => { + write!(svg_defs, r#""#).unwrap(); + } + } + input = result; + } + + svg_defs.push_str(""); + Some(id) +} + /// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the /// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. /// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. @@ -835,6 +859,7 @@ impl Render for List { for index in 0..self.len() { let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let filter_effects: Vec = self.attribute_cloned_or_default(ATTR_FILTER_EFFECTS, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let element = self.element(index).unwrap(); @@ -1061,9 +1086,9 @@ impl Render for List { let Some(vector) = self.element(index) else { continue }; let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let filter_effects: Vec = self.attribute_cloned_or_default(ATTR_FILTER_EFFECTS, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); - // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); @@ -1161,6 +1186,10 @@ impl Render for List { attributes.push(ATTR_TRANSFORM, matrix); } + if let Some(filter_id) = write_svg_filter_def(&mut attributes.0.svg_defs, &filter_effects) { + attributes.push("filter", format!("url(#{filter_id})")); + } + let defs = &mut attributes.0.svg_defs; if let Some((ref id, mask_type, ref vector_item)) = push_id { let mut svg = SvgRender::new(); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 3b08ac33d1..91c6d11ecf 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU}; use core::hash::{Hash, Hasher}; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; -use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn}; +use core_types::list::{ATTR_FILL, ATTR_FILTER_EFFECTS, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::transform::{Footprint, Transform}; use core_types::uuid::NodeId; @@ -15,6 +15,7 @@ use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Vector; use graphic_types::graphic::{bake_paint_transforms, graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute_at}; use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::SvgFilterEffect; use graphic_types::{Graphic, IntoGraphicList}; use kurbo::simplify::{SimplifyOptions, simplify_bezpath}; use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape}; @@ -230,6 +231,48 @@ async fn fill { + fn push_filter_effect(&mut self, effect: SvgFilterEffect) { + for effects in self.iter_attribute_values_mut_or_default::>(ATTR_FILTER_EFFECTS) { + effects.push(effect.clone()); + } + } +} + +impl FilterListIterMut for List { + fn push_filter_effect(&mut self, effect: SvgFilterEffect) { + for graphic in self.iter_element_values_mut() { + let Some(vector_list) = graphic.as_vector_mut() else { continue }; + vector_list.push_filter_effect(effect.clone()); + } + } +} + +#[node_macro::node(category("Vector: Style"), path(graphene_core::vector))] +async fn svg_gaussian_blur( + _: impl Ctx, + #[implementations(List, List)] + mut content: T, + #[range] + #[hard(0..)] + #[soft(..100)] + std_deviation: PixelLength, +) -> T +where + T: FilterListIterMut + 'n + Send, +{ + content.push_filter_effect(SvgFilterEffect::GaussianBlur { + std_deviation_x: std_deviation, + std_deviation_y: std_deviation, + }); + content +} + + trait IntoF64Vec { fn into_vec(self) -> Vec; }