From dc371da184c40198c72c0634c94c1d92d07bad07 Mon Sep 17 00:00:00 2001 From: Christopher Date: Sun, 28 Dec 2025 12:28:57 +1000 Subject: [PATCH 1/3] Add initial Colormap implementation with uniform and non-uniform keypoints - Introduce `Colormap` struct to map parameters to colors. - Support uniform and non-uniform keypoints map inputs. - Support for sRBG color interpolation for now. - Add unit tests: - Uniform and non-uniform keypoints. - Edge cases (empty colormap, single color). - Out-of-bounds parameter handling. This PR is part of issue #188 --- egui_plot/Cargo.toml | 2 +- egui_plot/src/colormap.rs | 294 ++++++++++++++++++++++++++++++++++++++ egui_plot/src/lib.rs | 1 + 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 egui_plot/src/colormap.rs diff --git a/egui_plot/Cargo.toml b/egui_plot/Cargo.toml index 624f318..df2d9b7 100644 --- a/egui_plot/Cargo.toml +++ b/egui_plot/Cargo.toml @@ -39,6 +39,7 @@ egui = { workspace = true, default-features = false } emath = { workspace = true, default-features = false } ahash.workspace = true +log.workspace = true #! ### Optional dependencies ## Enable this when generating docs. @@ -50,4 +51,3 @@ serde = { workspace = true, optional = true } assertables.workspace = true egui_kittest.workspace = true env_logger.workspace = true -log.workspace = true diff --git a/egui_plot/src/colormap.rs b/egui_plot/src/colormap.rs new file mode 100644 index 0000000..0007752 --- /dev/null +++ b/egui_plot/src/colormap.rs @@ -0,0 +1,294 @@ +#![allow(dead_code)] // While in development, keep colormap private. +use egui::Color32; + +/// Colormap is used to map some parameter (usually in the range [0.0, 1.0]) to [`egui::Color32`]. +/// +/// Currently, colors are interpolated from provided keypoints in sRGB space. +/// Keypoints may be uniformly distributed ([`Colormap::new_uniform`]) +/// or have explicit positions ([`Colormap::new_with_positions`]). +/// +/// If building your own Colormap with explicit positions, it is strongly recommended that +/// keypoints cover the full range from 0.0 to 1.0. This is because PlotItem consumers +/// make this assumption and will only sample the colormap in this range. +/// However, this is not enforced, so feel free to go against this for your own special use-cases. +pub struct Colormap { + data: ColormapData, +} + +impl Colormap { + /// Create a new colormap from a list of colors, uniformly distributed from 0.0 to 1.0. + /// + /// If no colors are provided, defaults to Color32::TRANSPARENT. + /// If a single color is provided, places the color at positions 0.0 and 1.0. + pub fn new_uniform(colors: Vec) -> Self { + Self { + data: ColormapData::new_uniform(colors), + } + } + + /// Create a new colormap from keypoints. + /// + /// If no keypoints are provided, defaults to Color32::TRANSPARENT. + /// If a single keypoint is provided, places the color at positions 0.0 and 1.0. + /// + /// PlotItem consumers will expect that keypoints cover the full range from 0.0 to 1.0, + /// but this is not enforced. Feel free to go against this for your own special use-cases, + /// but be aware that PlotItems will only consider the [0.0, 1.0] range. + pub fn new_with_positions(keypoints: Vec<(f32, Color32)>) -> Self { + Self { + data: ColormapData::new_nonuniform(keypoints), + } + } + + /// Get color at a given position. + pub fn get(&self, t: f32) -> Color32 { + self.data.get(t, ColorInterpolation::SRGB) + } +} + +// Internal representation of colormap data. +// May support things like functions in the future. +enum ColormapData { + Uniform(UniformKeypoints), + Nonuniform(NonuniformKeypoints), +} + +// Keypoints are uniformly distributed between 0.0 and 1.0 +struct UniformKeypoints { + colors: Vec, +} + +// Keypoints have explicit positions. Consumers expect positions to be in [0.0, 1.0], +// but this is not enforced. +struct NonuniformKeypoints { + keypoints: Vec<(f32, Color32)>, +} + +impl ColormapData { + // If no keypoints are provided, defaults to Color32::TRANSPARENT. + // If a single keypoint is provided, it is duplicated. + fn new_uniform(colors: Vec) -> Self { + if colors.is_empty() { + log::warn!("Colormap created with no colors, defaulting to transparent"); + return Self::Uniform(UniformKeypoints { + colors: vec![Color32::TRANSPARENT; 2], + }); + } + + if colors.len() == 1 { + log::warn!("Colormap created with a single color, duplicating it for full range"); + let single = colors[0]; + return Self::Uniform(UniformKeypoints { + colors: vec![single; 2], + }); + } + + Self::Uniform(UniformKeypoints { colors }) + } + + // If no keypoints are provided, defaults to Color32::TRANSPARENT. + // If a single keypoint is provided, it is duplicated at positions 0.0 and 1.0. + fn new_nonuniform(mut keypoints: Vec<(f32, Color32)>) -> Self { + if keypoints.is_empty() { + log::warn!("Colormap created with no keypoints, defaulting to transparent"); + return Self::Nonuniform(NonuniformKeypoints { + keypoints: vec![(0.0, Color32::TRANSPARENT), (1.0, Color32::TRANSPARENT)], + }); + } + + if keypoints.len() == 1 { + log::warn!("Colormap created with a single keypoint, duplicating it for full range"); + let single = keypoints[0]; + return Self::Nonuniform(NonuniformKeypoints { + keypoints: vec![(0.0, single.1), (1.0, single.1)], + }); + } + + // Ensure keypoints are sorted by position + keypoints.sort_by(|a, b| a.0.total_cmp(&b.0)); + + if let Some(&(first_pos, _)) = keypoints.first() + && first_pos != 0.0 + { + log::warn!("Colormap keypoints start at {}, instead of 0.0", first_pos); + } + if let Some(&(last_pos, _)) = keypoints.last() + && last_pos != 1.0 + { + log::warn!("Colormap keypoints end at {}, instead of 1.0", last_pos); + } + Self::Nonuniform(NonuniformKeypoints { keypoints }) + } + + fn get(&self, t: f32, interpolate: impl Fn(&Color32, &Color32, f32) -> Color32) -> Color32 { + match self { + ColormapData::Uniform(uniform) => uniform.get(t, interpolate), + ColormapData::Nonuniform(nonuniform) => nonuniform.get(t, interpolate), + } + } +} + +impl UniformKeypoints { + // Maps t in [0.0, 1.0] to colors uniformly distributed in the underlying colors array. + // Result is interpolated using the provided interpolation function. + fn get(&self, t: f32, interpolate: impl Fn(&Color32, &Color32, f32) -> Color32) -> Color32 { + let n = self.colors.len(); + if n == 0 { + return Color32::TRANSPARENT; + } + if n == 1 { + return self.colors[0]; + } + + // Map t from [0.0, 1.0] to a pair of sequential indexes in the colors array + let t = t.clamp(0.0, 1.0); + let scaled_t = t * (n - 1) as f32; + let index0 = scaled_t.floor() as usize; + let index1 = scaled_t.ceil() as usize; + + // Occurs when t is exactly on a keypoint + if index0 == index1 { + return self.colors[index0]; + } + + // Interpolate between the two colors + let color0 = self.colors[index0]; + let color1 = self.colors[index1]; + interpolate(&color0, &color1, scaled_t - index0 as f32) + } +} + +impl NonuniformKeypoints { + // Maps t to a pair of sequential keypoints in the underlying keypoints array. + // Result is interpolated using the provided interpolation function. + fn get(&self, t: f32, interpolate: impl Fn(&Color32, &Color32, f32) -> Color32) -> Color32 { + let Some(first) = self.keypoints.first() else { + return Color32::TRANSPARENT; + }; + let Some(last) = self.keypoints.last() else { + return Color32::TRANSPARENT; + }; + + if t <= first.0 { + return first.1; + } + if t >= last.0 { + return last.1; + } + + for i in 1..self.keypoints.len() { + let (t1, color1) = self.keypoints[i]; + // Find the first keypoint where t <= t1. It is implied that t0 <= t. + if t <= t1 { + let (t0, color0) = self.keypoints[i - 1]; + return interpolate(&color0, &color1, (t - t0) / (t1 - t0)); + } + } + + log::warn!("Colormap get reached unexpected code path, returning last color"); + last.1 + } +} + +/// Different methods for interpolating between colors. +/// +/// For those uninitiated in color interpolation, see +/// [this blog post](https://raphlinus.github.io/color/2021/01/18/oklab-critique.html) +/// for a great visual review of the behavior of different interpolation methods. +pub struct ColorInterpolation; + +impl ColorInterpolation { + /// Simple linear interpolation in sRGB space. + /// It is cheap to compute and works well for nearby colors, but can produce + /// perceptibly unexpected results for distant colors. + pub const SRGB: fn(&Color32, &Color32, f32) -> Color32 = srgb_interpolate; +} + +fn srgb_interpolate(c0: &Color32, c1: &Color32, t: f32) -> Color32 { + let t = t.clamp(0.0, 1.0); + let s = 1.0 - t; + let r = (c0.r() as f32 * s + c1.r() as f32 * t).round() as u8; + let g = (c0.g() as f32 * s + c1.g() as f32 * t).round() as u8; + let b = (c0.b() as f32 * s + c1.b() as f32 * t).round() as u8; + let a = (c0.a() as f32 * s + c1.a() as f32 * t).round() as u8; + Color32::from_rgba_premultiplied(r, g, b, a) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn colormap_uniform() { + let colors = vec![ + Color32::from_rgb(255, 0, 0), + Color32::from_rgb(0, 255, 0), + Color32::from_rgb(0, 0, 255), + ]; + let colormap = Colormap::new_uniform(colors); + assert_eq!(colormap.get(0.0), Color32::from_rgb(255, 0, 0)); + assert_eq!(colormap.get(0.25), Color32::from_rgb(128, 128, 0)); + assert_eq!(colormap.get(0.5), Color32::from_rgb(0, 255, 0)); + assert_eq!(colormap.get(0.75), Color32::from_rgb(0, 128, 128)); + assert_eq!(colormap.get(1.0), Color32::from_rgb(0, 0, 255)); + + // Out of bounds + assert_eq!(colormap.get(-0.5), Color32::from_rgb(255, 0, 0)); + assert_eq!(colormap.get(1.5), Color32::from_rgb(0, 0, 255)); + } + + #[test] + fn colormap_uniform_empty() { + let colors = vec![]; + let colormap = Colormap::new_uniform(colors); + for t in [-0.5, 0.0, 0.25, 0.5, 0.75, 1.0, 1.5] { + assert_eq!(colormap.get(t), Color32::TRANSPARENT); + } + } + + #[test] + fn colormap_uniform_single() { + let colors = vec![Color32::from_rgb(255, 0, 0)]; + let colormap = Colormap::new_uniform(colors); + for t in [-0.5, 0.0, 0.25, 0.5, 0.75, 1.0, 1.5] { + assert_eq!(colormap.get(t), Color32::from_rgb(255, 0, 0)); + } + } + + #[test] + fn colormap_nonuniform() { + let colors = vec![ + (0.0, Color32::from_rgb(254, 0, 0)), + (0.4, Color32::from_rgb(0, 254, 0)), + (1.0, Color32::from_rgb(0, 0, 254)), + ]; + let colormap = Colormap::new_with_positions(colors); + assert_eq!(colormap.get(0.0), Color32::from_rgb(254, 0, 0)); + assert_eq!(colormap.get(0.2), Color32::from_rgb(127, 127, 0)); + assert_eq!(colormap.get(0.4), Color32::from_rgb(0, 254, 0)); + assert_eq!(colormap.get(0.7), Color32::from_rgb(0, 127, 127)); + assert_eq!(colormap.get(1.0), Color32::from_rgb(0, 0, 254)); + + // Out of bounds + assert_eq!(colormap.get(-0.5), Color32::from_rgb(254, 0, 0)); + assert_eq!(colormap.get(1.5), Color32::from_rgb(0, 0, 254)); + } + + #[test] + fn colormap_nonuniform_empty() { + let colors = vec![]; + let colormap = Colormap::new_with_positions(colors); + for t in [-0.5, 0.0, 0.25, 0.5, 0.75, 1.0, 1.5] { + assert_eq!(colormap.get(t), Color32::TRANSPARENT); + } + } + + #[test] + fn colormap_nonuniform_single() { + let colors = vec![(0.3, Color32::from_rgb(0, 255, 0))]; + let colormap = Colormap::new_with_positions(colors); + for t in [-0.5, 0.0, 0.25, 0.5, 0.75, 1.0, 1.5] { + assert_eq!(colormap.get(t), Color32::from_rgb(0, 255, 0)); + } + } +} diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index fafa3fc..be5ae65 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -11,6 +11,7 @@ mod aesthetics; mod axis; mod bounds; +mod colormap; mod colors; mod cursor; mod data; From 5a0ed7094eca513f26bc344a5bf552b44c2aebd6 Mon Sep 17 00:00:00 2001 From: Christopher Date: Sun, 28 Dec 2025 12:49:07 +1000 Subject: [PATCH 2/3] Clarify documentation for Colormap. --- egui_plot/src/colormap.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/egui_plot/src/colormap.rs b/egui_plot/src/colormap.rs index 0007752..d6f298a 100644 --- a/egui_plot/src/colormap.rs +++ b/egui_plot/src/colormap.rs @@ -18,6 +18,9 @@ pub struct Colormap { impl Colormap { /// Create a new colormap from a list of colors, uniformly distributed from 0.0 to 1.0. /// + /// Uniform colormaps are simpler to define and evaluate in constant time, but are less + /// flexible than non-uniform colormaps. + /// /// If no colors are provided, defaults to Color32::TRANSPARENT. /// If a single color is provided, places the color at positions 0.0 and 1.0. pub fn new_uniform(colors: Vec) -> Self { @@ -28,6 +31,10 @@ impl Colormap { /// Create a new colormap from keypoints. /// + /// While being able to define positions gives maximum flexibility, note that evaluating + /// this colormap is currently O(n) in the number of keypoints, as opposed to O(1) for + /// uniform colormaps. + /// /// If no keypoints are provided, defaults to Color32::TRANSPARENT. /// If a single keypoint is provided, places the color at positions 0.0 and 1.0. /// @@ -195,7 +202,7 @@ impl NonuniformKeypoints { /// For those uninitiated in color interpolation, see /// [this blog post](https://raphlinus.github.io/color/2021/01/18/oklab-critique.html) /// for a great visual review of the behavior of different interpolation methods. -pub struct ColorInterpolation; +struct ColorInterpolation; impl ColorInterpolation { /// Simple linear interpolation in sRGB space. From cc357a3e5ee575bb68396786925cdfee07c3e364 Mon Sep 17 00:00:00 2001 From: Christopher Date: Sun, 28 Dec 2025 12:57:30 +1000 Subject: [PATCH 3/3] Make clippy happy --- egui_plot/src/colormap.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/egui_plot/src/colormap.rs b/egui_plot/src/colormap.rs index d6f298a..f20b106 100644 --- a/egui_plot/src/colormap.rs +++ b/egui_plot/src/colormap.rs @@ -1,4 +1,4 @@ -#![allow(dead_code)] // While in development, keep colormap private. +#![allow(dead_code, reason = "While in development, keep colormap private.")] use egui::Color32; /// Colormap is used to map some parameter (usually in the range [0.0, 1.0]) to [`egui::Color32`]. @@ -8,7 +8,7 @@ use egui::Color32; /// or have explicit positions ([`Colormap::new_with_positions`]). /// /// If building your own Colormap with explicit positions, it is strongly recommended that -/// keypoints cover the full range from 0.0 to 1.0. This is because PlotItem consumers +/// keypoints cover the full range from 0.0 to 1.0. This is because `PlotItem` consumers /// make this assumption and will only sample the colormap in this range. /// However, this is not enforced, so feel free to go against this for your own special use-cases. pub struct Colormap { @@ -21,7 +21,7 @@ impl Colormap { /// Uniform colormaps are simpler to define and evaluate in constant time, but are less /// flexible than non-uniform colormaps. /// - /// If no colors are provided, defaults to Color32::TRANSPARENT. + /// If no colors are provided, defaults to `Color32::TRANSPARENT`. /// If a single color is provided, places the color at positions 0.0 and 1.0. pub fn new_uniform(colors: Vec) -> Self { Self { @@ -35,12 +35,12 @@ impl Colormap { /// this colormap is currently O(n) in the number of keypoints, as opposed to O(1) for /// uniform colormaps. /// - /// If no keypoints are provided, defaults to Color32::TRANSPARENT. + /// If no keypoints are provided, defaults to `Color32::TRANSPARENT`. /// If a single keypoint is provided, places the color at positions 0.0 and 1.0. /// - /// PlotItem consumers will expect that keypoints cover the full range from 0.0 to 1.0, + /// `PlotItem` consumers will expect that keypoints cover the full range from 0.0 to 1.0, /// but this is not enforced. Feel free to go against this for your own special use-cases, - /// but be aware that PlotItems will only consider the [0.0, 1.0] range. + /// but be aware that plot items will only consider the [0.0, 1.0] range. pub fn new_with_positions(keypoints: Vec<(f32, Color32)>) -> Self { Self { data: ColormapData::new_nonuniform(keypoints), @@ -117,20 +117,20 @@ impl ColormapData { if let Some(&(first_pos, _)) = keypoints.first() && first_pos != 0.0 { - log::warn!("Colormap keypoints start at {}, instead of 0.0", first_pos); + log::warn!("Colormap keypoints start at {first_pos}, instead of 0.0"); } if let Some(&(last_pos, _)) = keypoints.last() && last_pos != 1.0 { - log::warn!("Colormap keypoints end at {}, instead of 1.0", last_pos); + log::warn!("Colormap keypoints end at {last_pos}, instead of 1.0"); } Self::Nonuniform(NonuniformKeypoints { keypoints }) } fn get(&self, t: f32, interpolate: impl Fn(&Color32, &Color32, f32) -> Color32) -> Color32 { match self { - ColormapData::Uniform(uniform) => uniform.get(t, interpolate), - ColormapData::Nonuniform(nonuniform) => nonuniform.get(t, interpolate), + Self::Uniform(uniform) => uniform.get(t, interpolate), + Self::Nonuniform(nonuniform) => nonuniform.get(t, interpolate), } } }