diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 75483971a8..28d81bb866 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -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 diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 7c0da3eb64..04957c314e 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -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, List, List, + List, List, List, List, @@ -303,6 +304,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -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, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 39ce7899e5..8347239f0a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -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)]); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 3149b1d717..e72ad3bcab 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -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; } diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 1bcaa67906..8291e0bfe8 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -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::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().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 { 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::>()) + }; + 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::>()) + }; 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::().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, ¶ms, &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 diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index a1373975d4..8a9bbb86c4 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -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); diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 122d4ef9d8..e884405ea5 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -25,6 +25,7 @@ export let allowInsert = true; export let allowDelete = true; export let allowReorder = true; + export let allowWrap = false; export let allowSelect = false; export let narrow = 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` // that would otherwise race the structural change before Rust has reported the dragged marker's new index. 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. - let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[] } | undefined = undefined; - // The run a hovered marker or dashed link would carry, highlighted ahead of the drag. + // Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first, + // their start positions for cancelling, and whether the run may cross the track's ends (only when dragged by a strip or dashed link). + 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; // 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. @@ -67,6 +69,29 @@ $: highlightedRun = 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) { 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 function markerPointerEnter(index: number) { if (allowSelect) return; - hoverRun = [index, index]; + hoverRun = markerShape(markers, index) === "Joined" ? [index, index + 1] : [index, index]; } function pointerPosition(e: MouseEvent, clamp = true): number | undefined { @@ -94,13 +119,27 @@ return clamp ? Math.max(0, Math.min(1, ratio)) : ratio; } - // Holds markers `first..=last` (spanning `spacing`) between their neighbors as they move to `position` - function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number { + // Holds markers `first..=last` (spanning `spacing`, the first having begun its drag at `start`) between their neighbors as they move to `position`. + // 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 - if (allowReorder && allowSelect) return position; - const lower = neighborBound(first, -1) ?? 0; - const upper = (neighborBound(last, 1) ?? 1) - spacing; - return Math.max(lower, Math.min(upper, position)); + const reorder = allowReorder && allowSelect; + let held = 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 @@ -111,16 +150,27 @@ return undefined; } - // The spans from each marker passing `linked` to its successor - function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { + // 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 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 }[] = []; markers.forEach((marker, index) => { const next = markers[index + 1]; 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]; - spans.push({ index, left, width: right - left }); + if (next.position > marker.position) spans.push({ index, left: marker.position, width: next.position - marker.position }); + 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; @@ -147,6 +197,19 @@ if (disabled) return; 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); 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 - function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean) { - const pointer = pointerPosition(e); + function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean, wrap: boolean) { + const pointer = pointerPosition(e, !wrap); if (pointer === undefined) return; 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 restore: number[] = []; for (let index = first; index <= last; index += 1) { const position = markers[index].position; - spacings.push(position - start); + spacings.push(allowWrap && position < start ? position + 1 - start : position - start); restore.push(position); } @@ -190,26 +254,38 @@ dragMoved = false; duplicateRequested = 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); 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] { - 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) { if (disabled || e.button !== BUTTON_LEFT) return; 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) { - beginMarkerDrag(e, index); - moveActiveMarker(e); + if (markerShape(markers, index) === "Joined") { + beginRunDrag(e, index, index + 1, false, false); + moveRun(e); + } else { + beginMarkerDrag(e, index); + moveActiveMarker(e); + } } function midpointPointerDown(e: PointerEvent, index: number) { @@ -231,7 +307,8 @@ function markerDoubleClick(index: number) { 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) { @@ -368,7 +445,7 @@ let position = pointerPosition(e); if (position === undefined) return; - position = holdBetweenNeighbors(dragIndex, dragIndex, 0, position); + position = holdBetweenNeighbors(dragIndex, dragIndex, dragRestorePosition ?? position, 0, position, false); dragMoved = true; if (!dragInsertedMarker) dispatch("dragging", true); @@ -376,22 +453,25 @@ } function moveRun(e: PointerEvent) { - if (disabled || dragRun === undefined) return; + if (disabled || dragRun === undefined || dragRestorePosition === undefined) return; if (e.buttons === 0) { endDrag(); return; } - const { first, last, offset, spacings } = dragRun; - const pointer = pointerPosition(e); + const { first, last, offset, spacings, wrap } = dragRun; + const pointer = pointerPosition(e, !wrap); if (pointer === undefined) return; 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; 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) { @@ -552,7 +632,8 @@ return positions; } $: 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(() => { document.addEventListener("keydown", deleteShortcut); @@ -615,10 +696,27 @@ on:dblclick={() => resetRun(...dashedRun(dash.index))} > {/each} + {#each strips as strip} +
= 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)} + >
+ {/each} {#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} = highlightedRun[0] && index <= highlightedRun[1]} style:--marker-position={marker.position} style:--marker-color={marker.handleColorCSS} @@ -630,14 +728,14 @@ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" > - + {#if disabled} - + + {/if} + + {#if shape === "Joined"} + {/if} - {/if} {/each} @@ -768,6 +866,19 @@ 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 { position: absolute; transform: translateX(-50%); @@ -779,6 +890,15 @@ padding-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 { fill: var(--marker-color); } @@ -807,9 +927,24 @@ --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(.active) { - .inner-fill:hover + .outer-border, + .inner-fill:hover ~ .outer-border, .outer-border:hover { fill: var(--color-6-lowergray); } @@ -818,7 +953,9 @@ &.active { 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)); } @@ -827,7 +964,7 @@ fill: var(--color-e-nearwhite); } - .inner-fill:hover + .outer-border, + .inner-fill:hover ~ .outer-border, .outer-border:hover { fill: var(--color-f-white); } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 7e5db6c200..215849c8d3 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -565,6 +565,7 @@ tagged_value! { SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), TonalRange(raster_nodes::adjustments::TonalRange), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), + HueSaturationRange(raster_nodes::adjustments::HueSaturationRange), GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), RowsOrColumns(vector::misc::RowsOrColumns), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 14ee46e2d5..c639fe2a7d 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -358,6 +358,7 @@ fn node_registry() -> HashMap>( 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: // 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 +// +// 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))] fn hue_saturation>( _: impl Ctx, @@ -710,27 +898,212 @@ fn hue_saturation>( )] #[gpu_image] input: Item, - hue_shift: Item, - saturation_shift: Item, - lightness_shift: Item, + hue: Item, + saturation: Item, + lightness: Item, + colorize: Item, + #[name("(Colorize) Hue")] + #[default(24.)] + colorize_hue: Item, + #[name("(Colorize) Saturation")] + #[default(25.)] + colorize_saturation: Item, + #[name("(Colorize) Lightness")] colorize_lightness: Item, + #[name("(Reds) Hue")] reds_hue: Item, + #[name("(Reds) Saturation")] reds_saturation: Item, + #[name("(Reds) Lightness")] reds_lightness: Item, + #[name("(Reds) Falloff Start")] + #[default(315.)] + reds_falloff_start: Item, + #[name("(Reds) Range Start")] + #[default(345.)] + reds_range_start: Item, + #[name("(Reds) Range End")] + #[default(15.)] + reds_range_end: Item, + #[name("(Reds) Falloff End")] + #[default(45.)] + reds_falloff_end: Item, + #[name("(Yellows) Hue")] yellows_hue: Item, + #[name("(Yellows) Saturation")] yellows_saturation: Item, + #[name("(Yellows) Lightness")] yellows_lightness: Item, + #[name("(Yellows) Falloff Start")] + #[default(15.)] + yellows_falloff_start: Item, + #[name("(Yellows) Range Start")] + #[default(45.)] + yellows_range_start: Item, + #[name("(Yellows) Range End")] + #[default(75.)] + yellows_range_end: Item, + #[name("(Yellows) Falloff End")] + #[default(105.)] + yellows_falloff_end: Item, + #[name("(Greens) Hue")] greens_hue: Item, + #[name("(Greens) Saturation")] greens_saturation: Item, + #[name("(Greens) Lightness")] greens_lightness: Item, + #[name("(Greens) Falloff Start")] + #[default(75.)] + greens_falloff_start: Item, + #[name("(Greens) Range Start")] + #[default(105.)] + greens_range_start: Item, + #[name("(Greens) Range End")] + #[default(135.)] + greens_range_end: Item, + #[name("(Greens) Falloff End")] + #[default(165.)] + greens_falloff_end: Item, + #[name("(Cyans) Hue")] cyans_hue: Item, + #[name("(Cyans) Saturation")] cyans_saturation: Item, + #[name("(Cyans) Lightness")] cyans_lightness: Item, + #[name("(Cyans) Falloff Start")] + #[default(135.)] + cyans_falloff_start: Item, + #[name("(Cyans) Range Start")] + #[default(165.)] + cyans_range_start: Item, + #[name("(Cyans) Range End")] + #[default(195.)] + cyans_range_end: Item, + #[name("(Cyans) Falloff End")] + #[default(225.)] + cyans_falloff_end: Item, + #[name("(Blues) Hue")] blues_hue: Item, + #[name("(Blues) Saturation")] blues_saturation: Item, + #[name("(Blues) Lightness")] blues_lightness: Item, + #[name("(Blues) Falloff Start")] + #[default(195.)] + blues_falloff_start: Item, + #[name("(Blues) Range Start")] + #[default(225.)] + blues_range_start: Item, + #[name("(Blues) Range End")] + #[default(255.)] + blues_range_end: Item, + #[name("(Blues) Falloff End")] + #[default(285.)] + blues_falloff_end: Item, + #[name("(Magentas) Hue")] magentas_hue: Item, + #[name("(Magentas) Saturation")] magentas_saturation: Item, + #[name("(Magentas) Lightness")] magentas_lightness: Item, + #[name("(Magentas) Falloff Start")] + #[default(255.)] + magentas_falloff_start: Item, + #[name("(Magentas) Range Start")] + #[default(285.)] + magentas_range_start: Item, + #[name("(Magentas) Range End")] + #[default(315.)] + magentas_range_end: Item, + #[name("(Magentas) Falloff End")] + #[default(345.)] + magentas_falloff_end: Item, + _range: Item, ) -> Item { let mut input = input; - let hue_shift = hue_shift.into_element(); - let saturation_shift = saturation_shift.into_element(); - let lightness_shift = lightness_shift.into_element(); + let master = HueSaturationSettings::new(hue.into_element(), saturation.into_element(), lightness.into_element()); + let colorize = colorize.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| { - // HSL operates on gamma-space channels - let [hue, saturation, lightness, alpha] = color.to_hsla(); + let [r, g, b, alpha] = color.to_gamma_srgb_channels(); - Color::from_hsla( - (hue + hue_shift / 360.) % 1., - // TODO: Improve the way saturation works (it's slightly off) - (saturation + saturation_shift / 100.).clamp(0., 1.), - // TODO: Fix the way lightness works (it's very off) - (lightness + lightness_shift / 100.).clamp(0., 1.), - alpha, - ) + if colorize { + let [_, _, lightness] = gamma_rgb_to_hsl(r, g, b); + let lightness = lightness_toward_white_or_black(lightness, colorize_settings.lightness); + let saturation = colorize_settings.saturation.max(0.); + let [r, g, b] = hsl_to_gamma_rgb(colorize_settings.hue, saturation, lightness); + return Color::from_gamma_srgb_channels(r, g, b, 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 } @@ -1573,8 +1946,8 @@ fn color_balance>( #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ - 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, }; graphene_hash::impl_via_hash!( DesaturateMethod, @@ -1589,6 +1962,7 @@ mod _graphene_hash_impls { SelectiveColorChoice, AdjustmentChannel, 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 /// 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] { @@ -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] fn invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5);