Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ impl ColorPickerMessageHandler {
groups.push(LayoutGroup::row(vec![
TextLabel::new("HSV")
.tooltip_label("Hue/Saturation/Value")
.tooltip_description("Also known as Hue/Saturation/Brightness (HSB). Not to be confused with Hue/Saturation/Lightness (HSL), a different color model.")
.tooltip_description("Also known as Hue/Saturation/Brightness (HSB), but distinct from Hue/Saturation/Lightness (HSL), a different color model.")
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
Expand Down Expand Up @@ -685,12 +685,7 @@ const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow.";
const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color.";
const VALUE_DESCRIPTION: &str = "The brightness from black to full color.";
const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%).";
const ENDS_DESCRIPTION: &str = "\
How the gradient continues beyond its ends:\n\
**Pad** extends the end colors outward.\n\
**Reflect** loops the gradient by mirroring back-and-forth.\n\
**Repeat** loops the gradient as copies of itself.\
";
const ENDS_DESCRIPTION: &str = "The method for how the gradient continues beyond its ends.";

/// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`).
/// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background.
Expand Down
2 changes: 1 addition & 1 deletion node-graph/libraries/core-types/src/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub const ATTR_BACKGROUND: &str = "background";
/// `bool` for whether an artboard clips content to its bounds.
pub const ATTR_CLIP: &str = "clip";
// TODO: Consider adding "gradient_spread_left" and "gradient_spread_right" override attributes to allow setting different gradient spreads on each side of a gradient
/// Gradient's `GradientSpread` (`Pad`, `Reflect`, or `Repeat`).
/// Gradient's `GradientSpread` (`Pad`, `Reflect`, `Repeat`, or `Clear`).
pub const ATTR_GRADIENT_SPREAD: &str = "gradient_spread";
/// Gradient's `GradientForm` (`Linear` or `Radial`).
pub const ATTR_GRADIENT_FORM: &str = "gradient_form";
Expand Down
8 changes: 5 additions & 3 deletions node-graph/libraries/rendering/src/render_ext.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::renderer::{RenderParams, format_transform_matrix, gradient_placement, transform_is_invertible};
use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible};
use crate::{Render, RenderSvgSegmentList, SvgRender};
use core_types::color::SRGBA8;
use core_types::list::List;
Expand Down Expand Up @@ -97,7 +97,9 @@ impl RenderExt for List<Gradient> {
let local_gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let gradient_spread: GradientSpread = self.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, 0);

for (position, color, original_midpoint) in stops.interpolated_samples() {
let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, ClearGuardPlacement::SvgStopOrder);

for (position, color, original_midpoint) in samples {
stop.push_str("<stop");
if position != 0. {
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
Expand Down Expand Up @@ -134,7 +136,7 @@ impl RenderExt for List<Gradient> {
format!(r#" gradientTransform="{gradient_transform}""#)
};

let gradient_spread = if gradient_spread == GradientSpread::Pad {
let gradient_spread = if matches!(gradient_spread, GradientSpread::Pad | GradientSpread::Clear) {
String::new()
} else {
format!(r#" spreadMethod="{}""#, gradient_spread.svg_name())
Expand Down
162 changes: 142 additions & 20 deletions node-graph/libraries/rendering/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,11 +394,79 @@ pub(crate) fn gradient_placement(transform: DAffine2, gradient_form: GradientFor
}
}

/// Texel count of the baked gradient ramp Vello samples stops through (`N_SAMPLES`/`GRADIENT_WIDTH` in vello_encoding).
const VELLO_GRADIENT_RAMP_TEXELS: f64 = 512.;

/// Renderable gradient samples of `(position, color, original midpoint)`, as produced by [`Gradient::interpolated_samples`].
type GradientSamples = Vec<(f64, Color, Option<f64>)>;

/// Where a renderer needs the transparent guard stops that emulate the `Clear` spread, which neither SVG nor Vello supports natively.
#[derive(Copy, Clone, PartialEq)]
pub(crate) enum ClearGuardPlacement {
/// Guards share the range ends' exact offsets, resolved against the visible colors by stop order alone.
SvgStopOrder,
/// Guards own the outermost ramp texel at each cleared end, since Vello's pad extension samples those texels for
/// everything beyond the ends and its ramp bake would tie-break a shared-offset guard away. The visible range
/// compresses inward by one texel per cleared end, costing about 0.4% of the ramp's color resolution.
VelloRampTexels,
}

/// The gradient's renderable samples plus the gradient-space span `(start, end)` the renderer's 0 to 1 offset range must cover, normally the unit interval with the samples unchanged.
///
/// The `Clear` spread brackets the samples with transparent guard stops placed per `guards`: the pad extension then
/// paints transparency outward while hard stops cut the paint off exactly at the unit range's boundaries. A radial
/// gradient's span still starts at zero, since its sampling distance never goes below the center.
pub(crate) fn spread_adjusted_samples(gradient: &Gradient, gradient_spread: GradientSpread, gradient_form: GradientForm, guards: ClearGuardPlacement) -> (GradientSamples, (f64, f64)) {
let samples = gradient.interpolated_samples();
if gradient_spread != GradientSpread::Clear {
return (samples, (0., 1.));
}

// The remapped offsets where the visible range's ends land, with the guards owning whatever lies outside them
let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.);
let (start_offset, end_offset) = match (guards, gradient_form) {
(ClearGuardPlacement::SvgStopOrder, _) => (0., 1.),
(ClearGuardPlacement::VelloRampTexels, GradientForm::Linear) => (texel, 1. - texel),
(ClearGuardPlacement::VelloRampTexels, GradientForm::Radial) => (0., 1. - texel),
};
let remap = |position: f64| (1. - position) * start_offset + position * end_offset;

// The geometric span grows to compensate for the compression, keeping the visible range at the unit interval
let scale = 1. / (end_offset - start_offset);
let span = (-start_offset * scale, (1. - start_offset) * scale);

// A stopless gradient paints solid black, matching `Gradient::evaluate`
let first_color = samples.first().map_or(Color::BLACK, |&(_, color, _)| color);
let last_color = samples.last().map_or(Color::BLACK, |&(_, color, _)| color);
let needs_start_anchor = samples.first().is_none_or(|&(position, ..)| position > 0.);
let needs_end_anchor = samples.last().is_none_or(|&(position, ..)| position < 1.);

let mut adjusted = Vec::with_capacity(samples.len() + 4);

// Lead with the transparent guard (linear only, a radial's center is already the sampling minimum), then anchor the visible range's start color
if gradient_form == GradientForm::Linear {
adjusted.push((0., Color::TRANSPARENT, None));
}
if needs_start_anchor {
adjusted.push((remap(0.), first_color, None));
}

adjusted.extend(samples.into_iter().map(|(position, color, midpoint)| (remap(position), color, midpoint)));

// Anchor the visible range's end color, then cut to the trailing transparent guard
if needs_end_anchor {
adjusted.push((remap(1.), last_color, None));
}
adjusted.push((1., Color::TRANSPARENT, None));

(adjusted, span)
}

/// Converts a gradient's renderer samples to peniko color stops, duplicating an off-zero first stop at position 0 since Vello ignores the first stop's position and always treats it as 0.
fn peniko_color_stops(gradient: &Gradient) -> peniko::ColorStops {
fn peniko_color_stops(samples: &[(f64, Color, Option<f64>)]) -> peniko::ColorStops {
let mut peniko_stops = peniko::ColorStops::new();

for (position, color, _) in gradient.interpolated_samples() {
for &(position, color, _) in samples {
let color = peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color());

if peniko_stops.is_empty() && position > 0. {
Expand All @@ -419,17 +487,27 @@ fn peniko_color_stops(gradient: &Gradient) -> peniko::ColorStops {
peniko_stops
}

/// The peniko extend mode for a spread; `Clear` rides pad, with the transparent guard stops from `spread_adjusted_samples` doing the clearing.
fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend {
match gradient_spread {
GradientSpread::Pad | GradientSpread::Clear => peniko::Extend::Pad,
GradientSpread::Reflect => peniko::Extend::Reflect,
GradientSpread::Repeat => peniko::Extend::Repeat,
}
}

fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;

let gradient_form: GradientForm = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0);
let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let gradient_spread: GradientSpread = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, 0);

let peniko_stops = peniko_color_stops(stops);
let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, ClearGuardPlacement::VelloRampTexels);
let peniko_stops = peniko_color_stops(&samples);

// The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse
let (start, end, gradient_to_device) = (DVec2::ZERO, DVec2::X, gradient_placement(multiplied_transform * gradient_transform, gradient_form));
let (start, end, gradient_to_device) = (DVec2::X * span.0, DVec2::X * span.1, gradient_placement(multiplied_transform * gradient_transform, gradient_form));

let brush = peniko::Brush::Gradient(peniko::Gradient {
kind: match gradient_form {
Expand All @@ -446,11 +524,7 @@ fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_trans
}
.into(),
},
extend: match gradient_spread {
GradientSpread::Pad => peniko::Extend::Pad,
GradientSpread::Reflect => peniko::Extend::Reflect,
GradientSpread::Repeat => peniko::Extend::Repeat,
},
extend: peniko_extend(gradient_spread),
stops: peniko_stops,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
..Default::default()
Expand Down Expand Up @@ -2111,8 +2185,10 @@ impl Render for List<Gradient> {
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
}

let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, ClearGuardPlacement::SvgStopOrder);

let mut stop_string = String::new();
for (position, color, original_midpoint) in gradient.interpolated_samples() {
for (position, color, original_midpoint) in samples {
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(stop_string, r#" stop-opacity="{}""#, color.a());
Expand All @@ -2133,7 +2209,7 @@ impl Render for List<Gradient> {
};

let gradient_id = generate_uuid();
let gradient_spread_attribute = if gradient_spread == GradientSpread::Pad {
let gradient_spread_attribute = if matches!(gradient_spread, GradientSpread::Pad | GradientSpread::Clear) {
String::new()
} else {
format!(r#" spreadMethod="{}""#, gradient_spread.svg_name())
Expand Down Expand Up @@ -2191,27 +2267,24 @@ impl Render for List<Gradient> {
let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;

let stops = peniko_color_stops(gradient);
let (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, ClearGuardPlacement::VelloRampTexels);
let stops = peniko_color_stops(&samples);

let extend = match gradient_spread {
GradientSpread::Pad => peniko::Extend::Pad,
GradientSpread::Reflect => peniko::Extend::Reflect,
GradientSpread::Repeat => peniko::Extend::Repeat,
};
let extend = peniko_extend(gradient_spread);

// The unit gradient line is the +X unit vector in local space, before the item's transform is applied.
// For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies.
let kind = match gradient_form {
GradientForm::Linear => peniko::LinearGradientPosition {
start: to_point(DVec2::ZERO),
end: to_point(DVec2::X),
start: to_point(DVec2::X * span.0),
end: to_point(DVec2::X * span.1),
}
.into(),
GradientForm::Radial => peniko::RadialGradientPosition {
start_center: to_point(DVec2::ZERO),
start_radius: 0.,
end_center: to_point(DVec2::ZERO),
end_radius: 1.,
end_radius: span.1 as f32,
}
.into(),
};
Expand Down Expand Up @@ -2672,3 +2745,52 @@ impl SvgRenderAttrs<'_> {
self.0.svg.push(value.into());
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn spread_adjusted_samples_wraps_clear_in_transparent_guards() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);

let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Repeat, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder);
assert_eq!(span, (0., 1.));
assert_eq!(samples, gradient.interpolated_samples());

// SVG guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder);
assert_eq!(span, (0., 1.));
assert_eq!(
samples,
vec![(0., Color::TRANSPARENT, None), (0., Color::BLACK, None), (1., Color::WHITE, None), (1., Color::TRANSPARENT, None)]
);

// Vello guards own the outermost ramp texels, with the visible range compressed inward to make room
let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.);
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::VelloRampTexels);
assert_eq!(
samples,
vec![
(0., Color::TRANSPARENT, None),
(texel, Color::BLACK, None),
(1. - texel, Color::WHITE, None),
(1., Color::TRANSPARENT, None)
]
);
assert!(span.0 < 0. && span.1 > 1., "the geometry must stretch to compensate for the compressed stops: {span:?}");

// A radial keeps its stops and span anchored at zero, with no guard below the center
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Radial, ClearGuardPlacement::VelloRampTexels);
assert_eq!(span.0, 0.);
assert_eq!(samples.first().unwrap(), &(0., Color::BLACK, None));
assert_eq!(samples.last().unwrap(), &(1., Color::TRANSPARENT, None));
}

#[test]
fn spread_adjusted_samples_keeps_a_stopless_clear_gradient_black_inside_the_range() {
let (samples, _) = spread_adjusted_samples(&Gradient::from(Vec::new()), GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder);
let colors: Vec<Color> = samples.iter().map(|&(_, color, _)| color).collect();
assert_eq!(colors, vec![Color::TRANSPARENT, Color::BLACK, Color::BLACK, Color::TRANSPARENT]);
}
}
28 changes: 27 additions & 1 deletion node-graph/libraries/vector-types/src/gradient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ impl Gradient {
let cycle = t.rem_euclid(2.);
if cycle > 1. { 2. - cycle } else { cycle }
}
GradientSpread::Clear => {
if !(0. ..=1.).contains(&t) {
return Color::TRANSPARENT;
}
t
}
};

let stops = self.normalized_stops();
Expand Down Expand Up @@ -797,7 +803,9 @@ pub enum GradientSpread {
/// Loops the gradient as copies of itself.
#[icon("GradientSpreadRepeat")]
Repeat,
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
/// Cuts off to transparency beyond the ends.
#[icon("GradientSpreadClear")]
Clear,
}

impl GradientSpread {
Expand All @@ -806,6 +814,8 @@ impl GradientSpread {
GradientSpread::Pad => "pad",
GradientSpread::Reflect => "reflect",
GradientSpread::Repeat => "repeat",
// SVG has no clear mode; renderers emulate it over pad with transparent guard stops
GradientSpread::Clear => "pad",
}
}

Expand Down Expand Up @@ -947,6 +957,22 @@ mod tests {
);
}

#[test]
fn clear_spread_evaluates_to_transparency_outside_the_unit_range() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);

assert_eq!(gradient.evaluate(-0.25, GradientSpread::Clear), Color::TRANSPARENT);
assert_eq!(gradient.evaluate(1.25, GradientSpread::Clear), Color::TRANSPARENT);

for t in [0., 0.25, 1.] {
assert_eq!(
gradient.evaluate(t, GradientSpread::Clear),
gradient.evaluate(t, GradientSpread::Pad),
"inside the range Clear must match Pad at t = {t}"
);
}
}

#[test]
fn gradient_ui_write_back_elides_default_attributes() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]);
Expand Down
4 changes: 2 additions & 2 deletions node-graph/nodes/math/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1385,7 +1385,7 @@ fn gradient_form(_: impl Ctx, gradient: Item<Gradient>, gradient_form: Item<vect
gradient
}

/// Sets how each gradient in the input list extends past its endpoints: Pad, Reflect, or Repeat.
/// Sets how each gradient in the input list extends past its endpoints: Pad, Reflect, Repeat, or Clear.
#[node_macro::node(category("Gradient"))]
fn gradient_spread(_: impl Ctx, gradient: Item<Gradient>, gradient_spread: Item<vector_types::GradientSpread>) -> Item<Gradient> {
let mut gradient = gradient;
Expand Down Expand Up @@ -1417,7 +1417,7 @@ fn gradient_midpoints(_: impl Ctx, gradient: Item<Gradient>, midpoints: List<f64
gradient
}

/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, or Repeat.
/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear.
#[node_macro::node(category("Color"))]
fn sample_gradient(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHITE)] gradient: Item<Gradient>, position: Item<Fraction>) -> Item<Color> {
let gradient_spread = gradient.attribute_cloned_or_default::<vector_types::GradientSpread>(core_types::ATTR_GRADIENT_SPREAD);
Expand Down
Loading