Add colorize and six hue ranges to the 'Hue/Saturation' node (#4536)

* Add colorize and six hue ranges to the 'Hue/Saturation' node

* Hide the 'Hue/Saturation' range rows while colorize is on, note the +100 saturation rule, and tidy two lints and a tooltip
This commit is contained in:
Keavon Chambers
2026-09-15 16:38:19 -07:00
committed by GitHub
parent fdf6cfe296
commit 8d111e37f5
10 changed files with 957 additions and 105 deletions
@@ -671,6 +671,9 @@ pub struct SpectrumInput {
/// Whether dragging a marker past another reorders them, which also needs `allow_select`. Otherwise the dragged marker is clamped between its neighbors.
#[serde(rename = "allowReorder")]
pub allow_reorder: bool,
/// Whether the track's ends meet, as on a hue wheel: a run dragged by its strip or dashed link wraps past them, a lone marker stops.
#[serde(rename = "allowWrap")]
pub allow_wrap: bool,
/// Whether clicking a marker selects it, keeping it highlighted and reported as the active marker until another is chosen,
/// as a gradient editor needs for the stop being edited. Otherwise the highlight only follows the pointer and the drag.
#[serde(rename = "allowSelect")]
@@ -703,7 +706,10 @@ pub struct SpectrumMarker {
/// discarding any transparency so the handle always shows the RGB that steers the interpolation.
#[serde(rename = "handleColorCSS")]
handle_color_css: String,
/// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers.
/// Whether this marker and the next form a split handle: one marker split down the middle while they coincide, two halves joined by a strip once apart.
#[serde(rename = "pairedWithNext")]
paired_with_next: bool,
/// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers, along with any split-handle halves attached to them.
#[serde(rename = "dashedToNext")]
dashed_to_next: bool,
/// Whether this marker follows its neighbors instead of bounding them, so they may drag past its drawn position.
@@ -718,6 +724,7 @@ impl SpectrumMarker {
position,
midpoint,
handle_color_css,
paired_with_next: false,
dashed_to_next: false,
between_neighbors: false,
}
@@ -728,6 +735,11 @@ impl SpectrumMarker {
self
}
pub fn pair_with_next(mut self) -> Self {
self.paired_with_next = true;
self
}
pub fn dash_to_next(mut self) -> Self {
self.dashed_to_next = true;
self
@@ -16,8 +16,8 @@ use graphene_std::list::{Item, List, NodeIdPath};
use graphene_std::math::float_noise::round_away_float_noise;
use graphene_std::memo::IORecord;
use graphene_std::raster::{
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice,
TonalRange,
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice, TonalRange,
};
use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::text::TextAlign;
@@ -245,6 +245,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
List<SelectiveColorChoice>,
List<TonalRange>,
List<AdjustmentChannel>,
List<HueSaturationRange>,
List<XY>,
List<ScaleType>,
List<ReferencePoint>,
@@ -303,6 +304,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
Item<SelectiveColorChoice>,
Item<TonalRange>,
Item<AdjustmentChannel>,
Item<HueSaturationRange>,
Item<XY>,
Item<ScaleType>,
Item<ReferencePoint>,
@@ -1074,6 +1076,7 @@ impl_table_item_layout_for_choice_enum!(
SelectiveColorChoice,
TonalRange,
AdjustmentChannel,
HueSaturationRange,
XY,
ScaleType,
CentroidType,
@@ -1294,6 +1297,7 @@ macro_rules! known_item_types {
SelectiveColorChoice,
TonalRange,
AdjustmentChannel,
HueSaturationRange,
XY,
ScaleType,
ReferencePoint,
@@ -3537,7 +3537,7 @@ impl DocumentMessageHandler {
})
.on_commit(|_| DocumentMessage::AddTransaction.into())
.max_width(100)
.tooltip_label("Fill")
.tooltip_label("Fill Opacity")
.widget_instance(),
];
let layers_panel_control_bar_left = Layout(vec![LayoutGroup::row(widgets)]);
@@ -664,7 +664,7 @@ impl<'a> ModifyInputsContext<'a> {
.nodes
.get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput));
if !current_input.is_some_and(|input| input.as_value().is_some()) {
if current_input.is_none_or(|input| input.as_value().is_none()) {
return;
}
@@ -688,7 +688,7 @@ impl<'a> ModifyInputsContext<'a> {
.nodes
.get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput));
if !current_input.is_some_and(|input| input.as_value().is_some()) {
if current_input.is_none_or(|input| input.as_value().is_none()) {
return;
}
@@ -20,8 +20,8 @@ use graphene_std::animation::RealTimeMode;
use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY;
use graphene_std::raster::{
AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice, TonalRange,
AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha,
RelativeAbsolute, SelectiveColorChoice, TonalRange,
};
use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TextAlign};
@@ -367,6 +367,7 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<RelativeAbsolute>(x) => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<TonalRange>(x) => enum_choice::<TonalRange>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<AdjustmentChannel>(x) => enum_choice::<AdjustmentChannel>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<HueSaturationRange>(x) => enum_choice::<HueSaturationRange>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<GridType>(x) => enum_choice::<GridType>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeJoin>(x) => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(),
@@ -1468,6 +1469,8 @@ enum MarkerScale {
Percent,
/// A gamma of 0.01..9.99 running from 9.99 at the left to 0.01 at the right, logarithmic on each side of the 1 at its center.
Gamma,
/// A hue of 0..360 degrees, placed linearly on a track that wraps around.
Degrees,
}
impl MarkerScale {
@@ -1476,6 +1479,11 @@ impl MarkerScale {
Self::Percent => value / 100.,
Self::Gamma if value >= 1. => 0.5 - 0.5 * value.log10() / 9.99_f64.log10(),
Self::Gamma => 0.5 + 0.5 * value.log10() / 0.01_f64.log10(),
Self::Degrees => {
// A full turn stays at the far end, so only a value beyond one turn wraps
let turns = value / 360.;
if (0.0..=1.).contains(&turns) { turns } else { turns.rem_euclid(1.) }
}
}
.clamp(0., 1.)
}
@@ -1485,6 +1493,7 @@ impl MarkerScale {
Self::Percent => (position * 100.).clamp(0., 100.),
Self::Gamma if position <= 0.5 => 9.99_f64.powf(1. - 2. * position).clamp(1., 9.99),
Self::Gamma => 0.01_f64.powf(2. * position - 1.).clamp(0.01, 1.),
Self::Degrees => (position * 360.).clamp(0., 360.),
}
}
@@ -1492,8 +1501,14 @@ impl MarkerScale {
match self {
Self::Percent => NumberInput::default().mode_increment().unit("%").min(0.).max(100.).display_decimal_places(0),
Self::Gamma => NumberInput::default().mode_increment().min(0.01).max(9.99).display_decimal_places(2),
Self::Degrees => NumberInput::default().mode_increment().unit("°").min(0.).max(360.).display_decimal_places(0),
}
}
/// Whether the track wraps around, so its markers may sit in any order.
fn cyclic(self) -> bool {
matches!(self, Self::Degrees)
}
}
/// One parameter of a shared spectrum section and how its marker sits on the track.
@@ -1503,6 +1518,8 @@ struct SpectrumSectionParam {
/// The value a double-click resets to.
default_value: f64,
scale: MarkerScale,
/// Whether the marker and the next parameter's marker form one split handle.
pair_with_next: bool,
/// Whether a dashed line joins the marker to the next parameter's marker.
dash_to_next: bool,
/// Whether the marker takes its scale position within the span between its neighbors rather than the whole track, following them as they move.
@@ -1516,6 +1533,7 @@ impl SpectrumSectionParam {
handle_color,
default_value,
scale,
pair_with_next: false,
dash_to_next: false,
between_neighbors: false,
}
@@ -1526,6 +1544,11 @@ impl SpectrumSectionParam {
self
}
fn pair_with_next(mut self) -> Self {
self.pair_with_next = true;
self
}
fn dash_to_next(mut self) -> Self {
self.dash_to_next = true;
self
@@ -1574,18 +1597,21 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
marker_default_positions.push(param.scale.position(param.default_value));
marker_scales.push(param.scale);
marker_between.push(param.between_neighbors);
marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker));
marker_colors_and_links.push((param.handle_color, param.pair_with_next && next_has_marker, param.dash_to_next && next_has_marker));
}
// Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence.
// A marker placed between its neighbors bounds nothing here and instead takes its scale position within their settled span.
let mut floor = 0.;
for (position, &between) in marker_positions.iter_mut().zip(&marker_between) {
if between {
continue;
let cyclic = params.iter().any(|param| param.scale.cyclic());
if !cyclic {
let mut floor = 0.;
for (position, &between) in marker_positions.iter_mut().zip(&marker_between) {
if between {
continue;
}
*position = position.max(floor);
floor = *position;
}
*position = position.max(floor);
floor = *position;
}
for i in 0..marker_positions.len() {
if marker_between[i] {
@@ -1599,8 +1625,11 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
.iter()
.zip(&marker_colors_and_links)
.zip(&marker_between)
.map(|((&position, &(handle_color, dashed)), &between)| {
.map(|((&position, &(handle_color, paired, dashed)), &between)| {
let mut marker = SpectrumMarker::new(position, 0.5, handle_color);
if paired {
marker = marker.pair_with_next();
}
if dashed {
marker = marker.dash_to_next();
}
@@ -1620,6 +1649,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
.allow_insert(false)
.allow_delete(false)
.allow_reorder(false)
.allow_wrap(cyclic)
.narrow(true)
.on_update({
let marker_input_indices = marker_input_indices.clone();
@@ -1654,7 +1684,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
}
SpectrumInputUpdate::MoveMarker { position, .. } => *position,
// A default that would cross a neighbor falls back to the midpoint between them
SpectrumInputUpdate::ResetMarker { .. } if between || (left..=right).contains(&default_position) => default_position,
SpectrumInputUpdate::ResetMarker { .. } if between || cyclic || (left..=right).contains(&default_position) => default_position,
SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2.,
_ => return Message::NoOp,
};
@@ -1713,58 +1743,178 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::hue_saturation::*;
// Current hue position on the rainbow track, used for the saturation track's right-end color
let current_hue_shift = get_document_node(node_id, context)
.ok()
.and_then(|document_node| document_node.input(HueShiftInput).and_then(|input| input.as_value()))
.and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value) } else { None })
.unwrap_or(0.);
// The rainbow has cyan at position 0.5 (hue_shift=0), so offset by +180 to align
let marker_hue = ((current_hue_shift + 180.) / 360.).rem_euclid(1.);
let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.);
let document_node = match get_document_node(node_id, context) {
Ok(document_node) => document_node,
Err(err) => {
log::error!("Could not get document node in hue_saturation_properties: {err}");
return Vec::new();
}
};
let colorize_value = matches!(document_node.input_value(ColorizeInput), Some(TaggedValue::Bool(true)));
let range_value = match document_node.input_value(RangeInput) {
Some(TaggedValue::HueSaturationRange(range)) => *range,
_ => HueSaturationRange::Master,
};
let f32_value = |parameter: &ParameterRef| match document_node.inputs.get(parameter.input_index).and_then(|input| input.as_value()) {
Some(TaggedValue::F32(value)) => *value,
_ => 0.,
};
// Hue: cyclic rainbow
// The three sliders of the master, of the colorize mode, or of the selected range
let (hue, saturation, lightness): (ParameterRef, ParameterRef, ParameterRef) = if colorize_value {
(ColorizeHueInput.into(), ColorizeSaturationInput.into(), ColorizeLightnessInput.into())
} else {
match range_value {
HueSaturationRange::Master => (HueInput.into(), SaturationInput.into(), LightnessInput.into()),
HueSaturationRange::Reds => (RedsHueInput.into(), RedsSaturationInput.into(), RedsLightnessInput.into()),
HueSaturationRange::Yellows => (YellowsHueInput.into(), YellowsSaturationInput.into(), YellowsLightnessInput.into()),
HueSaturationRange::Greens => (GreensHueInput.into(), GreensSaturationInput.into(), GreensLightnessInput.into()),
HueSaturationRange::Cyans => (CyansHueInput.into(), CyansSaturationInput.into(), CyansLightnessInput.into()),
HueSaturationRange::Blues => (BluesHueInput.into(), BluesSaturationInput.into(), BluesLightnessInput.into()),
HueSaturationRange::Magentas => (MagentasHueInput.into(), MagentasSaturationInput.into(), MagentasLightnessInput.into()),
}
};
let range_values: Option<[ParameterRef; 4]> = match range_value {
HueSaturationRange::Reds => Some([RedsFalloffStartInput.into(), RedsRangeStartInput.into(), RedsRangeEndInput.into(), RedsFalloffEndInput.into()]),
HueSaturationRange::Yellows => Some([
YellowsFalloffStartInput.into(),
YellowsRangeStartInput.into(),
YellowsRangeEndInput.into(),
YellowsFalloffEndInput.into(),
]),
HueSaturationRange::Greens => Some([GreensFalloffStartInput.into(), GreensRangeStartInput.into(), GreensRangeEndInput.into(), GreensFalloffEndInput.into()]),
HueSaturationRange::Cyans => Some([CyansFalloffStartInput.into(), CyansRangeStartInput.into(), CyansRangeEndInput.into(), CyansFalloffEndInput.into()]),
HueSaturationRange::Blues => Some([BluesFalloffStartInput.into(), BluesRangeStartInput.into(), BluesRangeEndInput.into(), BluesFalloffEndInput.into()]),
HueSaturationRange::Magentas => Some([
MagentasFalloffStartInput.into(),
MagentasRangeStartInput.into(),
MagentasRangeEndInput.into(),
MagentasFalloffEndInput.into(),
]),
HueSaturationRange::Master => None,
};
let range_defaults: Option<[f64; 4]> = match range_value {
HueSaturationRange::Reds => Some([315., 345., 15., 45.]),
HueSaturationRange::Yellows => Some([15., 45., 75., 105.]),
HueSaturationRange::Greens => Some([75., 105., 135., 165.]),
HueSaturationRange::Cyans => Some([135., 165., 195., 225.]),
HueSaturationRange::Blues => Some([195., 225., 255., 285.]),
HueSaturationRange::Magentas => Some([255., 285., 315., 345.]),
HueSaturationRange::Master => None,
};
// Every saturation track fades from one middle gray. Colorize and a range head for the hue they act on. The master track favors none,
// sweeping in OkLCh at the gray's lightness the long way from azure (220°) to magenta (330°), skipping the dull blue and purple, as chroma climbs to the gamut.
use color::ColorSpace as _;
let gray_lightness = 0.7;
let oklch = |lightness: f32, chroma: f32, hue: f32| {
let [r, g, b] = color::Oklch::to_linear_srgb([lightness, chroma, hue]);
Color::from_rgbf32_unchecked(r.clamp(0., 1.), g.clamp(0., 1.), b.clamp(0., 1.))
};
// Fades from the gray to the pure hue at `turns` with the chroma rising evenly while the lightness eases to the hue's own
let toward_hue = |turns: f32| {
let pure = Color::from_hsva(turns.rem_euclid(1.), 1., 1., 1.);
let [pure_lightness, pure_chroma, pure_hue] = color::Oklch::from_linear_srgb([pure.r(), pure.g(), pure.b()]);
let stops = 24;
let stop = |i: i32| {
let t = i as f32 / stops as f32;
oklch(gray_lightness + (pure_lightness - gray_lightness) * t, pure_chroma * t, pure_hue)
};
Gradient::from((0..=stops).map(stop).collect::<Vec<_>>())
};
let saturation_track = if colorize_value {
toward_hue(f32_value(&hue) / 360.)
} else if let Some([_, range_start, range_end, _]) = &range_values {
let (start, end) = (f32_value(range_start), f32_value(range_end));
let center = start + (end - start).rem_euclid(360.) / 2.;
toward_hue(center / 360.)
} else {
let in_gamut = |lightness: f32, chroma: f32, hue: f32| color::Oklch::to_linear_srgb([lightness, chroma, hue]).iter().all(|channel| (0.0..=1.).contains(channel));
let gamut_chroma = |lightness: f32, hue: f32| {
let (mut inside, mut outside) = (0., 0.4);
for _ in 0..16 {
let chroma = (inside + outside) / 2.;
if in_gamut(lightness, chroma, hue) {
inside = chroma;
} else {
outside = chroma;
}
}
inside
};
let stops = 80;
let stop = |i: i32| {
let t = i as f32 / stops as f32;
// A triangle wave gives every hue the same width, where a cosine would linger at its turnarounds
let bounce = (4. * t + 1.).rem_euclid(2.);
let along = if bounce <= 1. { bounce } else { 2. - bounce };
let hue = 330. + 250. * along;
oklch(gray_lightness, gamut_chroma(gray_lightness, hue) * t, hue)
};
Gradient::from((0..=stops).map(stop).collect::<Vec<_>>())
};
let hue_track = Gradient::from(vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED]);
// Saturation: gray to the fully saturated current hue
let saturation_track = Gradient::from(vec![Color::MIDDLE_GRAY, saturated_current_hue]);
// Lightness: black to white
let lightness_track = bw_track();
let (hue_min, hue_max, hue_default) = if colorize_value { (0., 360., 24.) } else { (-180., 180., 0.) };
let (saturation_min, saturation_default) = if colorize_value { (0., 25.) } else { (-100., 0.) };
vec![
// Colorize replaces the ranges, so while it is on the selector stays but grayed out and the selected range's edges hide
let mut range_info = ParameterWidgetsInfo::new(node_id, RangeInput, true, context);
range_info.exposable = false;
let mut layout = vec![enum_choice::<HueSaturationRange>().for_socket(range_info).disabled(colorize_value).property_row()];
layout.extend([
spectrum_slider_row(
node_id,
context,
HueShiftInput,
hue_track,
hue,
hue_track.clone(),
Color::WHITE,
-180.,
180.,
0.,
NumberInput::default().mode_increment().unit("°").min(-180.).max(180.),
hue_min,
hue_max,
hue_default,
NumberInput::default().mode_increment().unit("°").min(hue_min).max(hue_max),
),
spectrum_slider_row(
node_id,
context,
SaturationShiftInput,
saturation,
saturation_track,
Color::WHITE,
-100.,
saturation_min,
100.,
0.,
NumberInput::default().mode_increment().unit("%").min(-100.).max(100.),
saturation_default,
NumberInput::default().mode_increment().unit("%").min(saturation_min).max(100.),
),
spectrum_slider_row(
node_id,
context,
LightnessShiftInput,
lightness_track,
lightness,
bw_track(),
Color::WHITE,
-100.,
100.,
0.,
NumberInput::default().mode_increment().unit("%").min(-100.).max(100.),
),
]
]);
// The selected range's edges share one rainbow as two split handles, a falloff half joined to a range half, with the range dashed between them
if !colorize_value && let (Some(values), Some(defaults)) = (range_values, range_defaults) {
let [falloff_start, range_start, range_end, falloff_end] = values;
let params = [
SpectrumSectionParam::new(falloff_start, Color::WHITE, defaults[0], MarkerScale::Degrees).pair_with_next(),
SpectrumSectionParam::new(range_start, Color::WHITE, defaults[1], MarkerScale::Degrees).dash_to_next(),
SpectrumSectionParam::new(range_end, Color::WHITE, defaults[2], MarkerScale::Degrees).pair_with_next(),
SpectrumSectionParam::new(falloff_end, Color::WHITE, defaults[3], MarkerScale::Degrees),
];
build_shared_spectrum_section(node_id, context, &hue_track, &params, &mut layout);
}
let colorize = bool_widget(ParameterWidgetsInfo::new(node_id, ColorizeInput, true, context), CheckboxInput::default());
layout.push(LayoutGroup::row(colorize));
layout
}
/// A single-marker `SpectrumInput` over `track` driving the number at `input_index`: the marker sits at `position`, double-click
@@ -2221,6 +2221,17 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
inputs_count = 27;
}
// Hue/Saturation gained colorize and the six hue ranges after its three master sliders
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::hue_saturation::IDENTIFIER) && inputs_count == 4 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
for (index, input) in old_inputs.iter().take(4).enumerate() {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
}
inputs_count = 51;
}
if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);