mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-02 14:38:13 +08:00
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:
@@ -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, ¶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
|
/// 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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user