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. /// 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")] #[serde(rename = "allowReorder")]
pub allow_reorder: bool, 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, /// 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. /// as a gradient editor needs for the stop being edited. Otherwise the highlight only follows the pointer and the drag.
#[serde(rename = "allowSelect")] #[serde(rename = "allowSelect")]
@@ -703,7 +706,10 @@ pub struct SpectrumMarker {
/// discarding any transparency so the handle always shows the RGB that steers the interpolation. /// discarding any transparency so the handle always shows the RGB that steers the interpolation.
#[serde(rename = "handleColorCSS")] #[serde(rename = "handleColorCSS")]
handle_color_css: String, 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")] #[serde(rename = "dashedToNext")]
dashed_to_next: bool, dashed_to_next: bool,
/// Whether this marker follows its neighbors instead of bounding them, so they may drag past its drawn position. /// 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, position,
midpoint, midpoint,
handle_color_css, handle_color_css,
paired_with_next: false,
dashed_to_next: false, dashed_to_next: false,
between_neighbors: false, between_neighbors: false,
} }
@@ -728,6 +735,11 @@ impl SpectrumMarker {
self self
} }
pub fn pair_with_next(mut self) -> Self {
self.paired_with_next = true;
self
}
pub fn dash_to_next(mut self) -> Self { pub fn dash_to_next(mut self) -> Self {
self.dashed_to_next = true; self.dashed_to_next = true;
self 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::math::float_noise::round_away_float_noise;
use graphene_std::memo::IORecord; use graphene_std::memo::IORecord;
use graphene_std::raster::{ use graphene_std::raster::{
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
TonalRange, SelectiveColorChoice, TonalRange,
}; };
use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::text::TextAlign; 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<SelectiveColorChoice>,
List<TonalRange>, List<TonalRange>,
List<AdjustmentChannel>, List<AdjustmentChannel>,
List<HueSaturationRange>,
List<XY>, List<XY>,
List<ScaleType>, List<ScaleType>,
List<ReferencePoint>, List<ReferencePoint>,
@@ -303,6 +304,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
Item<SelectiveColorChoice>, Item<SelectiveColorChoice>,
Item<TonalRange>, Item<TonalRange>,
Item<AdjustmentChannel>, Item<AdjustmentChannel>,
Item<HueSaturationRange>,
Item<XY>, Item<XY>,
Item<ScaleType>, Item<ScaleType>,
Item<ReferencePoint>, Item<ReferencePoint>,
@@ -1074,6 +1076,7 @@ impl_table_item_layout_for_choice_enum!(
SelectiveColorChoice, SelectiveColorChoice,
TonalRange, TonalRange,
AdjustmentChannel, AdjustmentChannel,
HueSaturationRange,
XY, XY,
ScaleType, ScaleType,
CentroidType, CentroidType,
@@ -1294,6 +1297,7 @@ macro_rules! known_item_types {
SelectiveColorChoice, SelectiveColorChoice,
TonalRange, TonalRange,
AdjustmentChannel, AdjustmentChannel,
HueSaturationRange,
XY, XY,
ScaleType, ScaleType,
ReferencePoint, ReferencePoint,
@@ -3537,7 +3537,7 @@ impl DocumentMessageHandler {
}) })
.on_commit(|_| DocumentMessage::AddTransaction.into()) .on_commit(|_| DocumentMessage::AddTransaction.into())
.max_width(100) .max_width(100)
.tooltip_label("Fill") .tooltip_label("Fill Opacity")
.widget_instance(), .widget_instance(),
]; ];
let layers_panel_control_bar_left = Layout(vec![LayoutGroup::row(widgets)]); let layers_panel_control_bar_left = Layout(vec![LayoutGroup::row(widgets)]);
@@ -664,7 +664,7 @@ impl<'a> ModifyInputsContext<'a> {
.nodes .nodes
.get(&node_id) .get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput)); .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; return;
} }
@@ -688,7 +688,7 @@ impl<'a> ModifyInputsContext<'a> {
.nodes .nodes
.get(&node_id) .get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput)); .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; return;
} }
@@ -20,8 +20,8 @@ use graphene_std::animation::RealTimeMode;
use graphene_std::color::SRGBA8; use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY; use graphene_std::extract_xy::XY;
use graphene_std::raster::{ use graphene_std::raster::{
AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha,
SelectiveColorChoice, TonalRange, RelativeAbsolute, SelectiveColorChoice, TonalRange,
}; };
use graphene_std::raster_types::Image; use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TextAlign}; 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::<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::<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::<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::<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::<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(), Some(x) if id_is::<StrokeJoin>(x) => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(),
@@ -1468,6 +1469,8 @@ enum MarkerScale {
Percent, 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. /// 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, Gamma,
/// A hue of 0..360 degrees, placed linearly on a track that wraps around.
Degrees,
} }
impl MarkerScale { impl MarkerScale {
@@ -1476,6 +1479,11 @@ impl MarkerScale {
Self::Percent => value / 100., Self::Percent => value / 100.,
Self::Gamma if value >= 1. => 0.5 - 0.5 * value.log10() / 9.99_f64.log10(), 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::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.) .clamp(0., 1.)
} }
@@ -1485,6 +1493,7 @@ impl MarkerScale {
Self::Percent => (position * 100.).clamp(0., 100.), 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 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::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 { match self {
Self::Percent => NumberInput::default().mode_increment().unit("%").min(0.).max(100.).display_decimal_places(0), 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::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. /// 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. /// The value a double-click resets to.
default_value: f64, default_value: f64,
scale: MarkerScale, 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. /// Whether a dashed line joins the marker to the next parameter's marker.
dash_to_next: bool, 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. /// 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, handle_color,
default_value, default_value,
scale, scale,
pair_with_next: false,
dash_to_next: false, dash_to_next: false,
between_neighbors: false, between_neighbors: false,
} }
@@ -1526,6 +1544,11 @@ impl SpectrumSectionParam {
self self
} }
fn pair_with_next(mut self) -> Self {
self.pair_with_next = true;
self
}
fn dash_to_next(mut self) -> Self { fn dash_to_next(mut self) -> Self {
self.dash_to_next = true; self.dash_to_next = true;
self 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_default_positions.push(param.scale.position(param.default_value));
marker_scales.push(param.scale); marker_scales.push(param.scale);
marker_between.push(param.between_neighbors); 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. // 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. // A marker placed between its neighbors bounds nothing here and instead takes its scale position within their settled span.
let mut floor = 0.; let cyclic = params.iter().any(|param| param.scale.cyclic());
for (position, &between) in marker_positions.iter_mut().zip(&marker_between) { if !cyclic {
if between { let mut floor = 0.;
continue; 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() { for i in 0..marker_positions.len() {
if marker_between[i] { if marker_between[i] {
@@ -1599,8 +1625,11 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
.iter() .iter()
.zip(&marker_colors_and_links) .zip(&marker_colors_and_links)
.zip(&marker_between) .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); let mut marker = SpectrumMarker::new(position, 0.5, handle_color);
if paired {
marker = marker.pair_with_next();
}
if dashed { if dashed {
marker = marker.dash_to_next(); marker = marker.dash_to_next();
} }
@@ -1620,6 +1649,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
.allow_insert(false) .allow_insert(false)
.allow_delete(false) .allow_delete(false)
.allow_reorder(false) .allow_reorder(false)
.allow_wrap(cyclic)
.narrow(true) .narrow(true)
.on_update({ .on_update({
let marker_input_indices = marker_input_indices.clone(); 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, SpectrumInputUpdate::MoveMarker { position, .. } => *position,
// A default that would cross a neighbor falls back to the midpoint between them // 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., SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2.,
_ => return Message::NoOp, _ => 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> { pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::hue_saturation::*; use graphene_std::raster::hue_saturation::*;
// Current hue position on the rainbow track, used for the saturation track's right-end color let document_node = match get_document_node(node_id, context) {
let current_hue_shift = get_document_node(node_id, context) Ok(document_node) => document_node,
.ok() Err(err) => {
.and_then(|document_node| document_node.input(HueShiftInput).and_then(|input| input.as_value())) log::error!("Could not get document node in hue_saturation_properties: {err}");
.and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value) } else { None }) return Vec::new();
.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 colorize_value = matches!(document_node.input_value(ColorizeInput), Some(TaggedValue::Bool(true)));
let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.); 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]); 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 (hue_min, hue_max, hue_default) = if colorize_value { (0., 360., 24.) } else { (-180., 180., 0.) };
let saturation_track = Gradient::from(vec![Color::MIDDLE_GRAY, saturated_current_hue]); let (saturation_min, saturation_default) = if colorize_value { (0., 25.) } else { (-100., 0.) };
// Lightness: black to white
let lightness_track = bw_track();
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( spectrum_slider_row(
node_id, node_id,
context, context,
HueShiftInput, hue,
hue_track, hue_track.clone(),
Color::WHITE, Color::WHITE,
-180., hue_min,
180., hue_max,
0., hue_default,
NumberInput::default().mode_increment().unit("°").min(-180.).max(180.), NumberInput::default().mode_increment().unit("°").min(hue_min).max(hue_max),
), ),
spectrum_slider_row( spectrum_slider_row(
node_id, node_id,
context, context,
SaturationShiftInput, saturation,
saturation_track, saturation_track,
Color::WHITE, Color::WHITE,
-100., saturation_min,
100., 100.,
0., saturation_default,
NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), NumberInput::default().mode_increment().unit("%").min(saturation_min).max(100.),
), ),
spectrum_slider_row( spectrum_slider_row(
node_id, node_id,
context, context,
LightnessShiftInput, lightness,
lightness_track, bw_track(),
Color::WHITE, Color::WHITE,
-100., -100.,
100., 100.,
0., 0.,
NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), 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 /// 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; 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 { 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(); let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template); document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
@@ -25,6 +25,7 @@
export let allowInsert = true; export let allowInsert = true;
export let allowDelete = true; export let allowDelete = true;
export let allowReorder = true; export let allowReorder = true;
export let allowWrap = false;
export let allowSelect = false; export let allowSelect = false;
export let narrow = false; export let narrow = false;
export let rangeSlider = false; export let rangeSlider = false;
@@ -50,9 +51,10 @@
// Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker` // Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker`
// that would otherwise race the structural change before Rust has reported the dragged marker's new index. // that would otherwise race the structural change before Rust has reported the dragged marker's new index.
let skipNextMove = false; let skipNextMove = false;
// Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first, and their start positions for cancelling. // Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first,
let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[] } | undefined = undefined; // their start positions for cancelling, and whether the run may cross the track's ends (only when dragged by a strip or dashed link).
// The run a hovered marker or dashed link would carry, highlighted ahead of the drag. let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[]; wrap: boolean } | undefined = undefined;
// The run a hovered marker, strip, or dashed link would carry, highlighted ahead of the drag.
let hoverRun: [number, number] | undefined = undefined; let hoverRun: [number, number] | undefined = undefined;
// The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is. // The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is.
// Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes. // Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes.
@@ -67,6 +69,29 @@
$: highlightedRun = $: highlightedRun =
dragRun !== undefined ? [dragRun.first, dragRun.last] : hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined; dragRun !== undefined ? [dragRun.first, dragRun.last] : hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined;
type MarkerShape = "Whole" | "Joined" | "Left" | "Right" | "Hidden";
// The whole marker and its left and right halves, each as an inner fill and a 1px border ring in a 12x12 box
const WHOLE_PATHS = {
fill: "M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z",
border:
"M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4" +
"M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z",
};
const LEFT_HALF_PATHS = {
fill: "M6,0.7V11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1z",
border: "M6,0V12H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0zM5,2.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h3z",
};
const RIGHT_HALF_PATHS = {
fill: "M6,0.7V11.5h4c0.8,0,1.5-0.7,1.5-1.5V6.8c0-0.4-0.2-0.8-0.4-1.1z",
border: "M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H6zM7,2.4V11h3c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7z",
};
function shapePaths(shape: MarkerShape): { fill: string; border: string } {
if (shape === "Left") return LEFT_HALF_PATHS;
if (shape === "Right") return RIGHT_HALF_PATHS;
return WHOLE_PATHS;
}
function emit(intent: SpectrumInputUpdate) { function emit(intent: SpectrumInputUpdate) {
dispatch("update", intent); dispatch("update", intent);
} }
@@ -84,7 +109,7 @@
// Hovering highlights what a drag would carry, except where selecting is allowed and hover keeps its lighter tint // Hovering highlights what a drag would carry, except where selecting is allowed and hover keeps its lighter tint
function markerPointerEnter(index: number) { function markerPointerEnter(index: number) {
if (allowSelect) return; if (allowSelect) return;
hoverRun = [index, index]; hoverRun = markerShape(markers, index) === "Joined" ? [index, index + 1] : [index, index];
} }
function pointerPosition(e: MouseEvent, clamp = true): number | undefined { function pointerPosition(e: MouseEvent, clamp = true): number | undefined {
@@ -94,13 +119,27 @@
return clamp ? Math.max(0, Math.min(1, ratio)) : ratio; return clamp ? Math.max(0, Math.min(1, ratio)) : ratio;
} }
// Holds markers `first..=last` (spanning `spacing`) between their neighbors as they move to `position` // Holds markers `first..=last` (spanning `spacing`, the first having begun its drag at `start`) between their neighbors as they move to `position`.
function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number { // On a wrapping track the hold works in the unwrapped frame around `start`, and the result then wraps back or, without `wrap`, stops at the ends.
function holdBetweenNeighbors(first: number, last: number, start: number, spacing: number, position: number, wrap: boolean): number {
// Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors
if (allowReorder && allowSelect) return position; const reorder = allowReorder && allowSelect;
const lower = neighborBound(first, -1) ?? 0; let held = position;
const upper = (neighborBound(last, 1) ?? 1) - spacing;
return Math.max(lower, Math.min(upper, position)); if (!reorder && !allowWrap) {
const lower = neighborBound(first, -1) ?? 0;
const upper = (neighborBound(last, 1) ?? 1) - spacing;
held = Math.max(lower, Math.min(upper, position));
} else if (!reorder && last - first + 1 < markers.length) {
const lowerNeighbor = markers[(first + markers.length - 1) % markers.length].position;
const upperNeighbor = markers[(last + 1) % markers.length].position;
const lower = lowerNeighbor + Math.floor(start - lowerNeighbor);
const upper = upperNeighbor + Math.ceil(start + spacing - upperNeighbor) - spacing;
held = Math.max(lower, Math.min(upper, position));
}
if (!allowWrap) return held;
return wrap ? held - Math.floor(held) : Math.max(0, Math.min(1 - spacing, held));
} }
// The position of the nearest marker past `index` in the direction of `step` that bounds others, skipping any placed between its neighbors since those follow them instead // The position of the nearest marker past `index` in the direction of `step` that bounds others, skipping any placed between its neighbors since those follow them instead
@@ -111,16 +150,27 @@
return undefined; return undefined;
} }
// The spans from each marker passing `linked` to its successor // A marker paired with its successor draws as one marker split down the middle while the two coincide (the successor drawing nothing) and as a half once apart
function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { function markerShape(markers: SpectrumMarker[], index: number): MarkerShape {
const marker = markers[index];
const previous = markers[index - 1];
const next = markers[index + 1];
if (marker.pairedWithNext && next !== undefined) return next.position === marker.position ? "Joined" : "Left";
if (previous?.pairedWithNext) return previous.position === marker.position ? "Hidden" : "Right";
return "Whole";
}
// The spans from each marker passing `linked` to its successor, which on a wrapping track may cross the track's ends in two pieces
function markerSpans(markers: SpectrumMarker[], allowWrap: boolean, linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] {
const spans: { index: number; left: number; width: number }[] = []; const spans: { index: number; left: number; width: number }[] = [];
markers.forEach((marker, index) => { markers.forEach((marker, index) => {
const next = markers[index + 1]; const next = markers[index + 1];
if (!linked(marker) || next === undefined || next.position === marker.position) return; if (!linked(marker) || next === undefined || next.position === marker.position) return;
const [left, right] = next.position > marker.position ? [marker.position, next.position] : [next.position, marker.position]; if (next.position > marker.position) spans.push({ index, left: marker.position, width: next.position - marker.position });
spans.push({ index, left, width: right - left }); else if (allowWrap) spans.push({ index, left: marker.position, width: 1 - marker.position }, { index, left: 0, width: next.position });
else spans.push({ index, left: next.position, width: marker.position - next.position });
}); });
return spans; return spans;
@@ -147,6 +197,19 @@
if (disabled) return; if (disabled) return;
if (e.button === BUTTON_LEFT) { if (e.button === BUTTON_LEFT) {
// A joined pair drags as a whole, unless Alt breaks off the half under the pointer
if (markerShape(markers, index) === "Joined") {
if (!e.altKey) {
beginRunDrag(e, index, index + 1, true, false);
return;
}
const pointer = pointerPosition(e, false);
const half = pointer !== undefined && pointer >= markers[index].position ? index + 1 : index;
beginMarkerDrag(e, half);
return;
}
beginMarkerDrag(e, index); beginMarkerDrag(e, index);
return; return;
} }
@@ -170,16 +233,17 @@
} }
// Drags markers `first..=last` as one, keeping the pointer's offset from the first when `grabbed` and otherwise carrying the run to the pointer // Drags markers `first..=last` as one, keeping the pointer's offset from the first when `grabbed` and otherwise carrying the run to the pointer
function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean) { function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean, wrap: boolean) {
const pointer = pointerPosition(e); const pointer = pointerPosition(e, !wrap);
if (pointer === undefined) return; if (pointer === undefined) return;
const start = markers[first].position; const start = markers[first].position;
// Each member's forward gap from the first, so a run straddling the track's ends stays contiguous
const spacings: number[] = []; const spacings: number[] = [];
const restore: number[] = []; const restore: number[] = [];
for (let index = first; index <= last; index += 1) { for (let index = first; index <= last; index += 1) {
const position = markers[index].position; const position = markers[index].position;
spacings.push(position - start); spacings.push(allowWrap && position < start ? position + 1 - start : position - start);
restore.push(position); restore.push(position);
} }
@@ -190,26 +254,38 @@
dragMoved = false; dragMoved = false;
duplicateRequested = false; duplicateRequested = false;
duplicateActive = false; duplicateActive = false;
dragRun = { first, last, offset: grabbed ? start - pointer : 0, spacings, restore }; dragRun = { first, last, offset: grabbed ? start - pointer : 0, spacings, restore, wrap };
setActive(first, false); setActive(first, false);
addEvents(); addEvents();
} }
// The run a dashed link from `index` carries: the two markers it joins function stripPointerDown(e: PointerEvent, leftIndex: number) {
if (disabled || e.button !== BUTTON_LEFT) return;
beginRunDrag(e, leftIndex, leftIndex + 1, true, allowWrap);
}
// The run a dashed link from `index` carries: the two markers it joins plus any split-handle halves attached to them
function dashedRun(index: number): [number, number] { function dashedRun(index: number): [number, number] {
return [index, index + 1]; const first = markers[index - 1]?.pairedWithNext ? index - 1 : index;
const last = markers[index + 1]?.pairedWithNext && markers[index + 2] !== undefined ? index + 2 : index + 1;
return [first, last];
} }
function dashPointerDown(e: PointerEvent, index: number) { function dashPointerDown(e: PointerEvent, index: number) {
if (disabled || e.button !== BUTTON_LEFT) return; if (disabled || e.button !== BUTTON_LEFT) return;
const [first, last] = dashedRun(index); const [first, last] = dashedRun(index);
beginRunDrag(e, first, last, true); beginRunDrag(e, first, last, true, allowWrap);
} }
// Picks up the marker at `index` and carries it to the pointer // Picks up the marker at `index`, or the whole pair it is the joined half of, and carries it to the pointer
function pickUpMarker(e: PointerEvent, index: number) { function pickUpMarker(e: PointerEvent, index: number) {
beginMarkerDrag(e, index); if (markerShape(markers, index) === "Joined") {
moveActiveMarker(e); beginRunDrag(e, index, index + 1, false, false);
moveRun(e);
} else {
beginMarkerDrag(e, index);
moveActiveMarker(e);
}
} }
function midpointPointerDown(e: PointerEvent, index: number) { function midpointPointerDown(e: PointerEvent, index: number) {
@@ -231,7 +307,8 @@
function markerDoubleClick(index: number) { function markerDoubleClick(index: number) {
if (disabled || dragMoved) return; if (disabled || dragMoved) return;
emit({ ResetMarker: { index } }); if (markerShape(markers, index) === "Joined") resetRun(index, index + 1);
else emit({ ResetMarker: { index } });
} }
function resetRun(first: number, last: number) { function resetRun(first: number, last: number) {
@@ -368,7 +445,7 @@
let position = pointerPosition(e); let position = pointerPosition(e);
if (position === undefined) return; if (position === undefined) return;
position = holdBetweenNeighbors(dragIndex, dragIndex, 0, position); position = holdBetweenNeighbors(dragIndex, dragIndex, dragRestorePosition ?? position, 0, position, false);
dragMoved = true; dragMoved = true;
if (!dragInsertedMarker) dispatch("dragging", true); if (!dragInsertedMarker) dispatch("dragging", true);
@@ -376,22 +453,25 @@
} }
function moveRun(e: PointerEvent) { function moveRun(e: PointerEvent) {
if (disabled || dragRun === undefined) return; if (disabled || dragRun === undefined || dragRestorePosition === undefined) return;
if (e.buttons === 0) { if (e.buttons === 0) {
endDrag(); endDrag();
return; return;
} }
const { first, last, offset, spacings } = dragRun; const { first, last, offset, spacings, wrap } = dragRun;
const pointer = pointerPosition(e); const pointer = pointerPosition(e, !wrap);
if (pointer === undefined) return; if (pointer === undefined) return;
const span = spacings[spacings.length - 1]; const span = spacings[spacings.length - 1];
const start = holdBetweenNeighbors(first, last, span, pointer + offset); const start = holdBetweenNeighbors(first, last, dragRestorePosition, span, pointer + offset, wrap);
dragMoved = true; dragMoved = true;
dispatch("dragging", true); dispatch("dragging", true);
spacings.forEach((spacing, i) => emit({ MoveMarker: { index: first + i, position: start + spacing } })); spacings.forEach((spacing, i) => {
const position = start + spacing;
emit({ MoveMarker: { index: first + i, position: wrap ? position - Math.floor(position) : position } });
});
} }
function moveActiveMidpoint(e: PointerEvent) { function moveActiveMidpoint(e: PointerEvent) {
@@ -552,7 +632,8 @@
return positions; return positions;
} }
$: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic, trackInterpolation); $: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic, trackInterpolation);
$: dashes = markerSpans(markers, (marker) => marker.dashedToNext); $: strips = markerSpans(markers, allowWrap, (marker) => marker.pairedWithNext);
$: dashes = markerSpans(markers, allowWrap, (marker) => marker.dashedToNext);
onMount(() => { onMount(() => {
document.addEventListener("keydown", deleteShortcut); document.addEventListener("keydown", deleteShortcut);
@@ -615,10 +696,27 @@
on:dblclick={() => resetRun(...dashedRun(dash.index))} on:dblclick={() => resetRun(...dashedRun(dash.index))}
></div> ></div>
{/each} {/each}
{#each strips as strip}
<div
class="pair-strip"
class:active={highlightedRun !== undefined && strip.index >= highlightedRun[0] && strip.index < highlightedRun[1]}
style:--span-left={strip.left}
style:--span-width={strip.width}
style:--span-color={markers[strip.index].handleColorCSS}
on:pointerenter={() => (hoverRun = [strip.index, strip.index + 1])}
on:pointerleave={() => (hoverRun = undefined)}
on:pointerdown={(e) => stripPointerDown(e, strip.index)}
on:dblclick={() => resetRun(strip.index, strip.index + 1)}
></div>
{/each}
{#each markers as marker, index} {#each markers as marker, index}
{#if marker.position >= 0 && marker.position <= 1} {@const shape = markerShape(markers, index)}
{@const paths = shapePaths(shape)}
{#if shape !== "Hidden" && marker.position >= 0 && marker.position <= 1}
<svg <svg
class="marker" class="marker"
class:left={shape === "Left"}
class:right={shape === "Right"}
class:active={highlightedRun !== undefined && index >= highlightedRun[0] && index <= highlightedRun[1]} class:active={highlightedRun !== undefined && index >= highlightedRun[0] && index <= highlightedRun[1]}
style:--marker-position={marker.position} style:--marker-position={marker.position}
style:--marker-color={marker.handleColorCSS} style:--marker-color={marker.handleColorCSS}
@@ -630,14 +728,14 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12" viewBox="0 0 12 12"
> >
<path class="inner-fill" d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z" /> <path class="inner-fill" d={paths.fill} />
{#if disabled} {#if disabled}
<path class="disabled-fill" d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z" /> <path class="disabled-fill" d={paths.fill} />
{/if}
<path class="outer-border" d={paths.border} />
{#if shape === "Joined"}
<rect class="outer-border split-line" x="5.5" y="1.4" width="1" height="9.6" />
{/if} {/if}
<path
class="outer-border"
d="M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z"
/>
</svg> </svg>
{/if} {/if}
{/each} {/each}
@@ -768,6 +866,19 @@
pointer-events: auto; pointer-events: auto;
} }
.pair-strip {
position: absolute;
top: 4px;
left: calc(var(--span-left) * 100%);
width: calc(var(--span-width) * 100%);
height: 8px;
box-sizing: border-box;
border-top: 1px solid var(--color-5-dullgray);
border-bottom: 1px solid var(--color-5-dullgray);
background: rgb(from var(--span-color) r g b / 0.5);
pointer-events: auto;
}
.marker { .marker {
position: absolute; position: absolute;
transform: translateX(-50%); transform: translateX(-50%);
@@ -779,6 +890,15 @@
padding-top: 12px; padding-top: 12px;
margin-top: -12px; margin-top: -12px;
// A half's empty side neither draws nor takes the pointer
&.left {
clip-path: inset(0 50% 0 0);
}
&.right {
clip-path: inset(0 0 0 50%);
}
.inner-fill { .inner-fill {
fill: var(--marker-color); fill: var(--marker-color);
} }
@@ -807,9 +927,24 @@
--link-color: var(--color-e-nearwhite); --link-color: var(--color-e-nearwhite);
} }
&.disabled .marker-track .pair-strip {
border-color: var(--color-4-dimgray);
background-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5));
}
&:not(.disabled) .marker-track .pair-strip {
&:not(.active):hover {
border-color: var(--color-6-lowergray);
}
&.active {
border-color: var(--color-e-nearwhite);
}
}
&:not(.disabled) .marker-track .marker { &:not(.disabled) .marker-track .marker {
&:not(.active) { &:not(.active) {
.inner-fill:hover + .outer-border, .inner-fill:hover ~ .outer-border,
.outer-border:hover { .outer-border:hover {
fill: var(--color-6-lowergray); fill: var(--color-6-lowergray);
} }
@@ -818,7 +953,9 @@
&.active { &.active {
z-index: 1; z-index: 1;
.inner-fill { // The split line shares the halo, or its near-white would vanish into a light fill
.inner-fill,
.split-line {
filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack)); filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack));
} }
@@ -827,7 +964,7 @@
fill: var(--color-e-nearwhite); fill: var(--color-e-nearwhite);
} }
.inner-fill:hover + .outer-border, .inner-fill:hover ~ .outer-border,
.outer-border:hover { .outer-border:hover {
fill: var(--color-f-white); fill: var(--color-f-white);
} }
@@ -565,6 +565,7 @@ tagged_value! {
SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice),
TonalRange(raster_nodes::adjustments::TonalRange), TonalRange(raster_nodes::adjustments::TonalRange),
AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel),
HueSaturationRange(raster_nodes::adjustments::HueSaturationRange),
GridType(vector::misc::GridType), GridType(vector::misc::GridType),
ArcType(vector::misc::ArcType), ArcType(vector::misc::ArcType),
RowsOrColumns(vector::misc::RowsOrColumns), RowsOrColumns(vector::misc::RowsOrColumns),
@@ -358,6 +358,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
SelectiveColorChoice, SelectiveColorChoice,
TonalRange, TonalRange,
AdjustmentChannel, AdjustmentChannel,
HueSaturationRange,
Stroke, Stroke,
XY, XY,
ScaleType, ScaleType,
+554 -18
View File
@@ -697,9 +697,197 @@ fn black_and_white<T: Adjust<Color>>(
image image
} }
#[repr(u32)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
#[widget(Dropdown)]
pub enum HueSaturationRange {
#[default]
Master,
Reds,
Yellows,
Greens,
Cyans,
Blues,
Magentas,
}
/// HSL of gamma-encoded channels: hue in degrees, saturation and lightness in 0..1.
fn gamma_rgb_to_hsl(r: f32, g: f32, b: f32) -> [f32; 3] {
let maximum = r.max(g).max(b);
let minimum = r.min(g).min(b);
let chroma = maximum - minimum;
let lightness = (maximum + minimum) / 2.;
if chroma <= 0. {
return [0., 0., lightness];
}
let saturation = chroma / (1. - (2. * lightness - 1.).abs()).max(1e-6);
[hexagon_hue_degrees(r, g, b), saturation.min(1.), lightness]
}
/// Hexagon hue in degrees of three channels in any encoding, 0 for gray.
fn hexagon_hue_degrees(r: f32, g: f32, b: f32) -> f32 {
let maximum = r.max(g).max(b);
let chroma = maximum - r.min(g).min(b);
if chroma <= 0. {
return 0.;
}
let sector = if maximum == r {
wrap_positive((g - b) / chroma, 6.)
} else if maximum == g {
(b - r) / chroma + 2.
} else {
(r - g) / chroma + 4.
};
sector * 60.
}
/// `value` wrapped into the range from 0 to `modulus`.
fn wrap_positive(value: f32, modulus: f32) -> f32 {
value - (value / modulus).floor() * modulus
}
/// Gamma-encoded channels from a hue in degrees and saturation and lightness in 0..1.
fn hsl_to_gamma_rgb(hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
let chroma = (1. - (2. * lightness - 1.).abs()) * saturation;
let sector = wrap_positive(hue, 360.) / 60.;
let x = chroma * (1. - (wrap_positive(sector, 2.) - 1.).abs());
let (r, g, b) = if sector < 1. {
(chroma, x, 0.)
} else if sector < 2. {
(x, chroma, 0.)
} else if sector < 3. {
(0., chroma, x)
} else if sector < 4. {
(0., x, chroma)
} else if sector < 5. {
(x, 0., chroma)
} else {
(chroma, 0., x)
};
let m = lightness - chroma / 2.;
[(r + m).clamp(0., 1.), (g + m).clamp(0., 1.), (b + m).clamp(0., 1.)]
}
/// One set of Hue/Saturation sliders: a hue shift in degrees and saturation and lightness amounts in -1..1.
#[derive(Clone, Copy)]
struct HueSaturationSettings {
hue: f32,
saturation: f32,
lightness: f32,
}
impl HueSaturationSettings {
fn new(hue: f32, saturation_percent: f32, lightness_percent: f32) -> Self {
Self {
hue,
saturation: (saturation_percent / 100.).clamp(-1., 1.),
lightness: (lightness_percent / 100.).clamp(-1., 1.),
}
}
}
/// A hue range with its falloff: full weight from `range_start` to `range_end`, fading linearly to zero at the falloff ends.
#[derive(Clone, Copy)]
struct HueSaturationRangeSettings {
falloff_start: f32,
range_start: f32,
range_end: f32,
falloff_end: f32,
settings: HueSaturationSettings,
}
impl HueSaturationRangeSettings {
fn new(falloff_start: f32, range_start: f32, range_end: f32, falloff_end: f32, settings: HueSaturationSettings) -> Self {
// For PSD interop, each edge rounds to 1536 hue units per turn over 359 rather than 360 degrees, landing up to a degree late
let edge = |degrees: f32| (degrees * 1536. / 359.).round() * 360. / 1536.;
Self {
falloff_start: edge(falloff_start),
range_start: edge(range_start),
range_end: edge(range_end),
falloff_end: edge(falloff_end),
settings,
}
}
fn weight(&self, hue: f32) -> f32 {
let distance = |from: f32, to: f32| wrap_positive(to - from, 360.);
if distance(self.range_start, hue) <= distance(self.range_start, self.range_end) {
return 1.;
}
let start_falloff = distance(self.falloff_start, self.range_start);
let end_falloff = distance(self.range_end, self.falloff_end);
if distance(self.falloff_start, hue) < start_falloff {
return distance(self.falloff_start, hue) / start_falloff;
}
if distance(self.range_end, hue) < end_falloff {
return 1. - distance(self.range_end, hue) / end_falloff;
}
0.
}
}
/// The six ranges' combined effect on one pixel, gathered before the master sliders apply.
struct HueSaturationRangeEffect {
hue_shift: f32,
saturation_factor: f32,
fully_saturate: bool,
rgb: [f32; 3],
}
impl HueSaturationRangeEffect {
fn apply(&mut self, range: &HueSaturationRangeSettings, original_hue: f32, original_saturation: f32) {
// Range weights come from the original hue, and grays belong to no range
let weight = if original_saturation > 0. { range.weight(original_hue) } else { 0. };
if weight <= 0. {
return;
}
self.hue_shift += range.settings.hue * weight;
// For PSD interop, +100 saturates fully from the very edge of the falloff rather than scaling with the weight
if range.settings.saturation >= 1. {
self.fully_saturate = true;
} else {
self.saturation_factor *= 1. + (saturation_gain(range.settings.saturation) - 1.) * weight;
}
self.rgb = lightness_toward_max_or_min(self.rgb, range.settings.lightness * weight);
}
}
/// The factor a saturation amount in -1..1 applies to HSL saturation, quantized for PSD interop: 1 - trunc(256 a) / 256 below zero and floor(65280 / (255 - trunc(254 a))) / 256 above.
fn saturation_gain(amount: f32) -> f32 {
if amount < 0. {
1. - (-amount * 256.).trunc() / 256.
} else {
(65280. / (255. - (amount * 254.).trunc())).floor() / 256.
}
}
/// Blends toward white for a positive amount and toward black for a negative one, as the master lightness slider does.
fn lightness_toward_white_or_black(value: f32, amount: f32) -> f32 {
if amount >= 0. { value + (1. - value) * amount } else { value * (1. + amount) }
}
/// A range's lightness moves the channels toward the color's own maximum (positive) or minimum (negative) instead.
fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] {
let maximum = rgb[0].max(rgb[1]).max(rgb[2]);
let minimum = rgb[0].min(rgb[1]).min(rgb[2]);
let toward = if amount >= 0. { maximum } else { minimum };
let blend = |value: f32| value + (toward - value) * amount.abs();
[blend(rgb[0]), blend(rgb[1]), blend(rgb[2])]
}
// Aims for interoperable compatibility with: // Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0 // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings
//
// TODO: Residuals in 8-bit PSD interop: the byte-hue colorize table, the colorize lightness slider (up to 2.5 levels), and the range edges (a few tenths of a degree)
#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))] #[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))]
fn hue_saturation<T: Adjust<Color>>( fn hue_saturation<T: Adjust<Color>>(
_: impl Ctx, _: impl Ctx,
@@ -710,27 +898,212 @@ fn hue_saturation<T: Adjust<Color>>(
)] )]
#[gpu_image] #[gpu_image]
input: Item<T>, input: Item<T>,
hue_shift: Item<AngleF32>, hue: Item<AngleF32>,
saturation_shift: Item<SignedPercentageF32>, saturation: Item<SignedPercentageF32>,
lightness_shift: Item<SignedPercentageF32>, lightness: Item<SignedPercentageF32>,
colorize: Item<bool>,
#[name("(Colorize) Hue")]
#[default(24.)]
colorize_hue: Item<AngleF32>,
#[name("(Colorize) Saturation")]
#[default(25.)]
colorize_saturation: Item<PercentageF32>,
#[name("(Colorize) Lightness")] colorize_lightness: Item<SignedPercentageF32>,
#[name("(Reds) Hue")] reds_hue: Item<AngleF32>,
#[name("(Reds) Saturation")] reds_saturation: Item<SignedPercentageF32>,
#[name("(Reds) Lightness")] reds_lightness: Item<SignedPercentageF32>,
#[name("(Reds) Falloff Start")]
#[default(315.)]
reds_falloff_start: Item<f32>,
#[name("(Reds) Range Start")]
#[default(345.)]
reds_range_start: Item<f32>,
#[name("(Reds) Range End")]
#[default(15.)]
reds_range_end: Item<f32>,
#[name("(Reds) Falloff End")]
#[default(45.)]
reds_falloff_end: Item<f32>,
#[name("(Yellows) Hue")] yellows_hue: Item<AngleF32>,
#[name("(Yellows) Saturation")] yellows_saturation: Item<SignedPercentageF32>,
#[name("(Yellows) Lightness")] yellows_lightness: Item<SignedPercentageF32>,
#[name("(Yellows) Falloff Start")]
#[default(15.)]
yellows_falloff_start: Item<f32>,
#[name("(Yellows) Range Start")]
#[default(45.)]
yellows_range_start: Item<f32>,
#[name("(Yellows) Range End")]
#[default(75.)]
yellows_range_end: Item<f32>,
#[name("(Yellows) Falloff End")]
#[default(105.)]
yellows_falloff_end: Item<f32>,
#[name("(Greens) Hue")] greens_hue: Item<AngleF32>,
#[name("(Greens) Saturation")] greens_saturation: Item<SignedPercentageF32>,
#[name("(Greens) Lightness")] greens_lightness: Item<SignedPercentageF32>,
#[name("(Greens) Falloff Start")]
#[default(75.)]
greens_falloff_start: Item<f32>,
#[name("(Greens) Range Start")]
#[default(105.)]
greens_range_start: Item<f32>,
#[name("(Greens) Range End")]
#[default(135.)]
greens_range_end: Item<f32>,
#[name("(Greens) Falloff End")]
#[default(165.)]
greens_falloff_end: Item<f32>,
#[name("(Cyans) Hue")] cyans_hue: Item<AngleF32>,
#[name("(Cyans) Saturation")] cyans_saturation: Item<SignedPercentageF32>,
#[name("(Cyans) Lightness")] cyans_lightness: Item<SignedPercentageF32>,
#[name("(Cyans) Falloff Start")]
#[default(135.)]
cyans_falloff_start: Item<f32>,
#[name("(Cyans) Range Start")]
#[default(165.)]
cyans_range_start: Item<f32>,
#[name("(Cyans) Range End")]
#[default(195.)]
cyans_range_end: Item<f32>,
#[name("(Cyans) Falloff End")]
#[default(225.)]
cyans_falloff_end: Item<f32>,
#[name("(Blues) Hue")] blues_hue: Item<AngleF32>,
#[name("(Blues) Saturation")] blues_saturation: Item<SignedPercentageF32>,
#[name("(Blues) Lightness")] blues_lightness: Item<SignedPercentageF32>,
#[name("(Blues) Falloff Start")]
#[default(195.)]
blues_falloff_start: Item<f32>,
#[name("(Blues) Range Start")]
#[default(225.)]
blues_range_start: Item<f32>,
#[name("(Blues) Range End")]
#[default(255.)]
blues_range_end: Item<f32>,
#[name("(Blues) Falloff End")]
#[default(285.)]
blues_falloff_end: Item<f32>,
#[name("(Magentas) Hue")] magentas_hue: Item<AngleF32>,
#[name("(Magentas) Saturation")] magentas_saturation: Item<SignedPercentageF32>,
#[name("(Magentas) Lightness")] magentas_lightness: Item<SignedPercentageF32>,
#[name("(Magentas) Falloff Start")]
#[default(255.)]
magentas_falloff_start: Item<f32>,
#[name("(Magentas) Range Start")]
#[default(285.)]
magentas_range_start: Item<f32>,
#[name("(Magentas) Range End")]
#[default(315.)]
magentas_range_end: Item<f32>,
#[name("(Magentas) Falloff End")]
#[default(345.)]
magentas_falloff_end: Item<f32>,
_range: Item<HueSaturationRange>,
) -> Item<T> { ) -> Item<T> {
let mut input = input; let mut input = input;
let hue_shift = hue_shift.into_element(); let master = HueSaturationSettings::new(hue.into_element(), saturation.into_element(), lightness.into_element());
let saturation_shift = saturation_shift.into_element(); let colorize = colorize.into_element();
let lightness_shift = lightness_shift.into_element(); let colorize_settings = HueSaturationSettings::new(colorize_hue.into_element(), colorize_saturation.into_element(), colorize_lightness.into_element());
let (reds, yellows, greens, cyans, blues, magentas) = (
HueSaturationRangeSettings::new(
reds_falloff_start.into_element(),
reds_range_start.into_element(),
reds_range_end.into_element(),
reds_falloff_end.into_element(),
HueSaturationSettings::new(reds_hue.into_element(), reds_saturation.into_element(), reds_lightness.into_element()),
),
HueSaturationRangeSettings::new(
yellows_falloff_start.into_element(),
yellows_range_start.into_element(),
yellows_range_end.into_element(),
yellows_falloff_end.into_element(),
HueSaturationSettings::new(yellows_hue.into_element(), yellows_saturation.into_element(), yellows_lightness.into_element()),
),
HueSaturationRangeSettings::new(
greens_falloff_start.into_element(),
greens_range_start.into_element(),
greens_range_end.into_element(),
greens_falloff_end.into_element(),
HueSaturationSettings::new(greens_hue.into_element(), greens_saturation.into_element(), greens_lightness.into_element()),
),
HueSaturationRangeSettings::new(
cyans_falloff_start.into_element(),
cyans_range_start.into_element(),
cyans_range_end.into_element(),
cyans_falloff_end.into_element(),
HueSaturationSettings::new(cyans_hue.into_element(), cyans_saturation.into_element(), cyans_lightness.into_element()),
),
HueSaturationRangeSettings::new(
blues_falloff_start.into_element(),
blues_range_start.into_element(),
blues_range_end.into_element(),
blues_falloff_end.into_element(),
HueSaturationSettings::new(blues_hue.into_element(), blues_saturation.into_element(), blues_lightness.into_element()),
),
HueSaturationRangeSettings::new(
magentas_falloff_start.into_element(),
magentas_range_start.into_element(),
magentas_range_end.into_element(),
magentas_falloff_end.into_element(),
HueSaturationSettings::new(magentas_hue.into_element(), magentas_saturation.into_element(), magentas_lightness.into_element()),
),
);
input.element_mut().adjust(|color| { input.element_mut().adjust(|color| {
// HSL operates on gamma-space channels let [r, g, b, alpha] = color.to_gamma_srgb_channels();
let [hue, saturation, lightness, alpha] = color.to_hsla();
Color::from_hsla( if colorize {
(hue + hue_shift / 360.) % 1., let [_, _, lightness] = gamma_rgb_to_hsl(r, g, b);
// TODO: Improve the way saturation works (it's slightly off) let lightness = lightness_toward_white_or_black(lightness, colorize_settings.lightness);
(saturation + saturation_shift / 100.).clamp(0., 1.), let saturation = colorize_settings.saturation.max(0.);
// TODO: Fix the way lightness works (it's very off) let [r, g, b] = hsl_to_gamma_rgb(colorize_settings.hue, saturation, lightness);
(lightness + lightness_shift / 100.).clamp(0., 1.), return Color::from_gamma_srgb_channels(r, g, b, alpha);
alpha, }
)
// Each range weights its sliders by its falloff around the original hue: hue shifts add and saturation gains multiply
let [original_hue, original_saturation, _] = gamma_rgb_to_hsl(r, g, b);
let mut effect = HueSaturationRangeEffect {
hue_shift: master.hue,
saturation_factor: 1.,
fully_saturate: false,
rgb: [r, g, b],
};
effect.apply(&reds, original_hue, original_saturation);
effect.apply(&yellows, original_hue, original_saturation);
effect.apply(&greens, original_hue, original_saturation);
effect.apply(&cyans, original_hue, original_saturation);
effect.apply(&blues, original_hue, original_saturation);
effect.apply(&magentas, original_hue, original_saturation);
let HueSaturationRangeEffect {
hue_shift,
mut saturation_factor,
mut fully_saturate,
rgb,
} = effect;
if master.saturation >= 1. {
fully_saturate = true;
} else {
saturation_factor *= saturation_gain(master.saturation);
}
// The master lightness blends toward white or black before the hue and saturation, which work in HSL of the gamma channels
let rgb = [
lightness_toward_white_or_black(rgb[0], master.lightness),
lightness_toward_white_or_black(rgb[1], master.lightness),
lightness_toward_white_or_black(rgb[2], master.lightness),
];
let [hue, saturation, lightness] = gamma_rgb_to_hsl(rgb[0], rgb[1], rgb[2]);
let saturation = if saturation <= 0. {
0.
} else if fully_saturate {
1.
} else {
(saturation * saturation_factor).min(1.)
};
let [r, g, b] = hsl_to_gamma_rgb(hue + hue_shift, saturation, lightness);
Color::from_gamma_srgb_channels(r, g, b, alpha)
}); });
input input
} }
@@ -1573,8 +1946,8 @@ fn color_balance<T: Adjust<Color>>(
#[cfg(feature = "std")] #[cfg(feature = "std")]
mod _graphene_hash_impls { mod _graphene_hash_impls {
use super::{ use super::{
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha,
SelectiveColorChoice, TonalRange, RelativeAbsolute, SelectiveColorChoice, TonalRange,
}; };
graphene_hash::impl_via_hash!( graphene_hash::impl_via_hash!(
DesaturateMethod, DesaturateMethod,
@@ -1589,6 +1962,7 @@ mod _graphene_hash_impls {
SelectiveColorChoice, SelectiveColorChoice,
AdjustmentChannel, AdjustmentChannel,
TonalRange, TonalRange,
HueSaturationRange
); );
} }
@@ -1603,6 +1977,12 @@ mod tests {
} }
} }
fn assert_close_with_label(actual: [f32; 3], expected: [f32; 3], label: &str) {
for channel in 0..3 {
assert!((actual[channel] - expected[channel]).abs() <= 1.5, "{label}: expected {expected:?}, got {actual:?}");
}
}
/// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points /// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points
/// on one gamma-space gray value (0..255), returning the red and green results on the same scale. /// on one gamma-space gray value (0..255), returning the red and green results on the same scale.
fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] { fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] {
@@ -1667,6 +2047,162 @@ mod tests {
} }
} }
/// Runs the node on one gamma-space RGB value (0..255) with the master sliders, colorize, and one range's sliders at
/// its default range values, returning the gamma-space result on the same scale.
fn run_hue_saturation(input: [f32; 3], master: [f32; 3], colorize: Option<[f32; 3]>, range: Option<(HueSaturationRange, [f32; 3])>) -> [f32; 3] {
let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.);
let colorize_values = colorize.unwrap_or([24., 25., 0.]);
let range_values = |which: HueSaturationRange| match range {
Some((selected, values)) if selected == which => values,
_ => [0., 0., 0.],
};
let [reds, yellows, greens, cyans, blues, magentas] = [
range_values(HueSaturationRange::Reds),
range_values(HueSaturationRange::Yellows),
range_values(HueSaturationRange::Greens),
range_values(HueSaturationRange::Cyans),
range_values(HueSaturationRange::Blues),
range_values(HueSaturationRange::Magentas),
];
let result = hue_saturation(
(),
Item::new_from_element(pixel),
master[0].into(),
master[1].into(),
master[2].into(),
colorize.is_some().into(),
colorize_values[0].into(),
colorize_values[1].into(),
colorize_values[2].into(),
reds[0].into(),
reds[1].into(),
reds[2].into(),
315_f32.into(),
345_f32.into(),
15_f32.into(),
45_f32.into(),
yellows[0].into(),
yellows[1].into(),
yellows[2].into(),
15_f32.into(),
45_f32.into(),
75_f32.into(),
105_f32.into(),
greens[0].into(),
greens[1].into(),
greens[2].into(),
75_f32.into(),
105_f32.into(),
135_f32.into(),
165_f32.into(),
cyans[0].into(),
cyans[1].into(),
cyans[2].into(),
135_f32.into(),
165_f32.into(),
195_f32.into(),
225_f32.into(),
blues[0].into(),
blues[1].into(),
blues[2].into(),
195_f32.into(),
225_f32.into(),
255_f32.into(),
285_f32.into(),
magentas[0].into(),
magentas[1].into(),
magentas[2].into(),
255_f32.into(),
285_f32.into(),
315_f32.into(),
345_f32.into(),
HueSaturationRange::Master.into(),
);
let [r, g, b, _] = result.into_element().to_gamma_srgb_channels();
[r * 255., g * 255., b * 255.]
}
#[test]
fn hue_saturation_master_sliders_rotate_scale_and_lighten() {
assert_close_with_label(run_hue_saturation([200., 50., 50.], [30., 0., 0.], None, None), [200., 125., 50.], "hue +30");
assert_close_with_label(run_hue_saturation([60., 120., 200.], [30., 0., 0.], None, None), [70., 60., 200.], "hue +30 on blue");
assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., 50., 0.], None, None), [250., 0., 0.], "saturation +50");
assert_close_with_label(run_hue_saturation([60., 120., 200.], [0., 50., 0.], None, None), [5., 111., 255.], "saturation +50 on blue");
assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., -50., 0.], None, None), [162., 87., 87.], "saturation -50");
assert_close_with_label(run_hue_saturation([30., 200., 90.], [0., -50., 0.], None, None), [72., 157., 102.], "saturation -50 on green");
assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., 0., 50.], None, None), [227., 152., 152.], "lightness +50");
assert_close_with_label(run_hue_saturation([250., 0., 130.], [0., 0., 50.], None, None), [252., 127., 192.], "lightness +50 on magenta");
assert_close_with_label(run_hue_saturation([60., 120., 200.], [0., 0., -50.], None, None), [30., 60., 100.], "lightness -50");
assert_close_with_label(run_hue_saturation([200., 50., 50.], [90., 60., -40.], None, None), [74., 150., 0.], "combined");
assert_close_with_label(run_hue_saturation([30., 200., 90.], [90., 60., -40.], None, None), [0., 20., 138.], "combined on green");
assert_close_with_label(run_hue_saturation([150., 150., 150.], [90., 60., -40.], None, None), [90., 90., 90.], "combined on gray");
}
#[test]
fn hue_saturation_colorize_rebuilds_the_exact_hsl_color() {
assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., 0., 0.], Some([240., 100., 0.]), None), [0., 0., 250.], "colorize 240/100/0");
assert_close_with_label(run_hue_saturation([150., 150., 150.], [0., 0., 0.], Some([240., 100., 0.]), None), [45., 45., 255.], "colorize on gray");
assert_close_with_label(run_hue_saturation([30., 200., 90.], [0., 0., 0.], Some([60., 100., 0.]), None), [230., 230., 0.], "colorize 60/100/0");
assert_close_with_label(
run_hue_saturation([150., 150., 150.], [0., 0., 0.], Some([30., 60., -30.]), None),
[168., 104., 42.],
"colorize 30/60/-30",
);
assert_close_with_label(run_hue_saturation([120., 0., 30.], [0., 0., 0.], Some([30., 60., -30.]), None), [67., 42., 17.], "colorize dark");
assert_close_with_label(
run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([20., 100., 0.]), None),
[255., 85., 0.],
"colorize 20 is the exact HSL color",
);
assert_close_with_label(run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([160., 100., 0.]), None), [0., 255., 170.], "colorize 160");
assert_close_with_label(run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([340., 100., 0.]), None), [255., 0., 85.], "colorize 340");
assert_close_with_label(
run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([-20., 100., 0.]), None),
[255., 0., 85.],
"colorize -20 wraps to 340",
);
}
#[test]
fn hue_saturation_ranges_weight_their_sliders_by_falloff() {
let reds = HueSaturationRange::Reds;
assert_close_with_label(
run_hue_saturation([200., 50., 50.], [0., 0., 0.], None, Some((reds, [60., 0., 0.]))),
[199., 200., 50.],
"reds hue +60 inside",
);
assert_close_with_label(
run_hue_saturation([60., 120., 200.], [0., 0., 0.], None, Some((reds, [60., 0., 0.]))),
[60., 120., 200.],
"reds hue +60 outside",
);
assert_close_with_label(
run_hue_saturation([200., 50., 50.], [0., 0., 0.], None, Some((reds, [0., 100., 0.]))),
[250., 0., 0.],
"reds saturation +100",
);
assert_close_with_label(
run_hue_saturation([200., 50., 50.], [0., 0., 0.], None, Some((reds, [0., 0., -50.]))),
[125., 50., 50.],
"reds lightness -50",
);
assert_close_with_label(
run_hue_saturation([255., 65., 0.], [0., 0., 0.], None, Some((reds, [0., 0., -50.]))),
[129., 33., 0.],
"reds lightness -50 near the edge",
);
assert_close_with_label(
run_hue_saturation([30., 200., 90.], [0., 0., 0.], None, Some((HueSaturationRange::Greens, [0., 50., -25.]))),
[0., 196., 69.],
"greens saturation and lightness",
);
assert_close_with_label(
run_hue_saturation([200., 50., 50.], [30., 0., 0.], None, Some((reds, [0., 50., 0.]))),
[250., 125., 0.],
"master hue with a range saturation",
);
}
#[test] #[test]
fn invert_flips_straight_channels_and_keeps_alpha() { fn invert_flips_straight_channels_and_keeps_alpha() {
let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5);