From 29540adc8f8ef2c44803797589a5bed271113b1a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 8 Aug 2026 04:37:44 -0700 Subject: [PATCH] New node: 'Gradient to Colors' for unwrapping a gradient ramp into its colors (#4425) * New node: 'Gradient to Colors' for unwrapping a gradient ramp into its colors * Update 'Gradient Midpoints' wording --- node-graph/nodes/graphic/src/graphic.rs | 56 +++++++++++++++++++++++-- node-graph/nodes/graphic/src/record.rs | 45 +++++++++++++++++++- node-graph/nodes/math/src/lib.rs | 4 +- 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 7ade088f5f..1e033d8a7e 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -1,10 +1,11 @@ use crate::record::Inherited; use core_types::arena::Arena; -use core_types::attribute::{Attr, EditorLayerPath, Name0, Named, Opacity, OpacityFill, Transform as TransformAttr, WireValue}; +use core_types::attribute::{Attr, Attribute, EditorLayerPath, Midpoint, Name0, Named, Opacity, OpacityFill, Position, Transform as TransformAttr, WireValue}; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::extent::{LevelIn, ListIn, ValueIn}; use core_types::gpoll::{ErrorKind, Extent, GPoll, GraphError, Interrupt}; use core_types::list::List; +use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION}; use core_types::node::Lane; use core_types::registry::types::Angle; use core_types::uuid::NodeId; @@ -15,6 +16,7 @@ use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr}; use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector}; use raster_types::{CPU, GPU, Raster}; use vector_types::gradient::{GradientForm as GradientFormValue, GradientHueDirection, GradientSpace, GradientSpread}; +use vector_types::markers::GradientCyclic; use vector_types::{Gradient, ReferencePoint}; fn arena_exhausted() -> Interrupt { @@ -559,10 +561,58 @@ pub fn flatten_gradient<'e>( flatten_leaf_lane(content, ctx.index() as usize) } -/// Constructs a gradient from a `Color[]`, where the colors are evenly distributed as gradient stops across the range from 0 to 1. +/// A gradient over a level of colors: a `position` or `midpoint` column on the level places its stops, and a level carrying neither distributes them evenly. +fn gradient_of_lanes(colors: core_types::node::List<'_, Color>) -> Gradient { + let layout = colors.batch().layout(); + let has_position = layout.offset_of(Position::NAME, 0).is_some(); + let has_midpoint = layout.offset_of(Midpoint::NAME, 0).is_some(); + + let mut list = List::new(); + for index in 0..colors.len() { + let lane = colors.lane(index); + list.push(core_types::list::Item::new_from_element(colors.get(index))); + if has_position { + list.set_attribute(ATTR_POSITION, index, lane.attr::()); + } + if has_midpoint { + list.set_attribute(ATTR_MIDPOINT, index, lane.attr::()); + } + } + Gradient::from(list) +} + +/// Constructs a gradient from a `Color[]`, where each color becomes a gradient stop. A `position` attribute on the colors places their stops along the ramp and a `midpoint` attribute skews each transition, while colors carrying neither are distributed evenly across the 0 to 1 range. #[node_macro::node(category("Gradient"), name("Colors to Gradient"))] pub fn colors_to_gradient(_: impl Ctx, colors: IList) -> Gradient { - Gradient::from(colors.iter().collect::>()) + gradient_of_lanes(colors) +} + +/// Unwraps each gradient into the `Color[]` of its stops, each carrying its `position` and `midpoint` along the ramp. Attributes belonging to the gradient as a whole (like spread and interpolation), rather than its individual color stops, are not preserved. +#[node_macro::node(category("Gradient"), name("Gradient to Colors"), extent(gradient_to_colors_extent))] +pub fn gradient_to_colors<'e>( + ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy, + gradients: IList, +) -> Result, Attr<'e, Midpoint>)>, Interrupt> { + let mut remaining = ctx.index() as usize; + for row in 0..gradients.len() { + let gradient = gradients.element_ref(row); + if remaining >= gradient.len() { + remaining -= gradient.len(); + continue; + } + let gradient_cyclic = gradients.lane(row).attr::(); + let Some(color) = gradient.color(remaining) else { break }; + return Ok((color, Attr(gradient.position(remaining, gradient_cyclic)), Attr(gradient.midpoint(remaining)))); + } + Err(GraphError::past_end().into()) +} + +/// The level holds every gradient's stops in order. +fn gradient_to_colors_extent(gradients: ListIn<'_, Gradient>, level: LevelIn) -> GPoll { + match level.top() { + true => gradients.get().map(|gradients| Extent::Exactly((0..gradients.len()).map(|row| gradients.element_ref(row).len()).sum())), + false => GPoll::Final(Extent::Exactly(1)), + } } /// The gradient over a graphic level's color leaves, as [`colors_to_gradient`]. diff --git a/node-graph/nodes/graphic/src/record.rs b/node-graph/nodes/graphic/src/record.rs index 840d269863..5fa772169a 100644 --- a/node-graph/nodes/graphic/src/record.rs +++ b/node-graph/nodes/graphic/src/record.rs @@ -223,7 +223,10 @@ fn flatten_levels_extent(content: ExtentIn<'_>, level: LevelIn) -> GPoll #[cfg(test)] mod tests { use super::*; - use crate::graphic::{ColorsToGradientNode, FlattenColorNode, FlattenGraphicNode, WrapGraphicNode, flatten_color_layout_meta, flatten_graphic_layout_meta, wrap_graphic_layout_meta}; + use crate::graphic::{ + ColorsToGradientNode, FlattenColorNode, FlattenGraphicNode, GradientToColorsNode, WrapGraphicNode, flatten_color_layout_meta, flatten_graphic_layout_meta, gradient_to_colors_layout_meta, + wrap_graphic_layout_meta, + }; use core_types::arena::Arena; use core_types::attribute::Attribute as AttributeMarker; use core_types::context::{ContextImpl, ExtractArena}; @@ -736,6 +739,46 @@ mod tests { } } + /// The stops of a gradient level unwrap to color lanes carrying their + /// effective placement, which a `Colors to Gradient` reads back. + #[test] + fn gradient_stops_round_trip_through_color_lanes() { + let frames = core_types::record::test_frames(1 << 16); + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE]); + gradient.set_positions(&[0., 0.25, 1.]); + gradient.set_midpoints(&[0.3, 0.5, 0.5]); + let source = core_types::value::LeveledValueSource::new(vec![gradient.clone()]); + let layout = Node::::layout(&source).clone(); + let colors = install(GradientToColorsNode::new(source), gradient_to_colors_layout_meta(), &[Some(&layout)]); + let colors_layout = Node::::layout(&colors).clone(); + assert_eq!(colors.extent_at(&ctx, 0, &frames.reborrow()), GPoll::Final(Extent::Exactly(3))); + + let head = ctx.index_head(); + for (lane, (color, position, midpoint)) in [(Color::RED, 0., 0.3), (Color::GREEN, 0.25, 0.5), (Color::BLUE, 1., 0.5)].into_iter().enumerate() { + let GPoll::Final(record) = record::capture(&colors, &ctx.promoted(&head, lane as u64), &frames) else { + panic!("expected a final record"); + }; + assert_eq!(record.element::(), color, "lane {lane}"); + assert_eq!(record.attr::(), position, "lane {lane}"); + assert_eq!(record.attr::(), midpoint, "lane {lane}"); + } + + let out = Layout::default().with_writes(0, record::element_write_hashed::(), &[]); + let restored = install_flip(ColorsToGradientNode::new(colors, &colors_layout), &out); + let GPoll::Final(record) = record::capture(&restored, &ctx, &frames) else { + panic!("expected a final record"); + }; + let restored = record.element::(); + assert_eq!(restored.positions(false), gradient.positions(false)); + assert_eq!(restored.midpoints(), gradient.midpoints()); + assert_eq!(restored.iter().map(|stop| stop.color).collect::>(), vec![Color::RED, Color::GREEN, Color::BLUE]); + } + #[test] fn colors_fold_into_evenly_spaced_stops() { let frames = core_types::record::test_frames(1 << 16); diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 1c3de0d743..bc2a293796 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1245,9 +1245,9 @@ fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList gradient } -/// Sets the interpolation midpoint for each interval between gradient stops, a factor from 0 to 1 where the 0.5 default means linear interpolation and another value skews the transition speed toward one stop or the other. +/// Skews how rapidly the color flows across each interval between color stops, bunching up the transition toward one end instead of progressing uniformly. Each value places the halfway color within its corresponding interval, measured as a fraction of the distance (0 to 1) between the adjacent stops. A 0.5 midpoint keeps a uniform transition rate through the interval. /// -/// The final stop's midpoint controls the wrap back around to the first stop when the gradient is cyclic, and is otherwise ignored. +/// Non-cyclic gradients have no interval following the last stop, meaning the midpoint is ignored in that position. /// /// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each midpoint to its default of 0.5. #[node_macro::node(category("Gradient"))]