Skip to content

Commit

Permalink
move documentation from color::core to color::Color, where it is actu…
Browse files Browse the repository at this point in the history
…ally seen; add convenience constructors for P3 and Okl**
  • Loading branch information
apparebit committed Jun 7, 2024
1 parent 85167be commit 77a437f
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 106 deletions.
190 changes: 169 additions & 21 deletions src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ pub use super::util::Coordinate;
/// higher-quality in-gamut color.
///
///
/// # Equality and Hashes
/// # Equality Testing and Hashing
///
/// So that [`Self::hash`](struct.Color.html#method.hash) is the same for colors
/// that compare [`Self::eq`], the two methods use the same normalization
/// strategy:
/// The key requirement for equality testing and hashing is that colors that
/// compare [`Self::eq`] also have the same
/// [`Self::hash`](struct.Color.html#method.hash). To maintain this invariant,
/// the implementation of the two methods normalizes coordinates:
///
/// * To make coordinates comparable, replace not-a-numbers with positive
/// zero;
Expand All @@ -45,10 +46,11 @@ pub use super::util::Coordinate;
/// * To allow for floating point error, multiply by 1e14 and then round,
/// which drops the least significant digit;
/// * To make zeros comparable, replace negative zero with positive zero (but
/// only after rounding, which may produce zeros)
/// only after rounding, which may produce zeros);
/// * To convince Rust that coordinates are comparable, convert to bits.
///
/// While rounding isn't strictly necessary for correctness, it makes for a more
/// robust comparison without giving up meaningful precision.
/// robust comparison without meaningfully reducing precision.
#[derive(Copy, Clone, Debug)]
pub struct Color {
space: ColorSpace,
Expand Down Expand Up @@ -85,6 +87,51 @@ impl Color {
}
}

/// Instantiate a new Display P3 color with the given red, green, and blue
/// coordinates.
///
/// ```
/// # use prettypretty::{Color, ColorSpace};
/// let cyan = Color::p3(0.0, 1.0, 1.0);
/// assert_eq!(cyan.coordinates(), &[0.0, 1.0, 1.0]);
/// ```
pub const fn p3(r: f64, g: f64, b: f64) -> Self {
Color {
space: ColorSpace::DisplayP3,
coordinates: [r, g, b],
}
}

/// Instantiate a new Oklab color with the given lightness, a, and b
/// coordinates.
///
/// ```
/// # use prettypretty::{Color, ColorSpace};
/// let blue_cyanish = Color::oklab(0.78, -0.1, -0.1);
/// assert_eq!(blue_cyanish.space(), ColorSpace::Oklab);
/// ```
pub const fn oklab(l: f64, a: f64, b: f64) -> Self {
Color {
space: ColorSpace::Oklab,
coordinates: [l, a, b],
}
}

/// Instantiate a new Oklch color with the given lightness, chroma, and hue
/// coordinates.
///
/// ```
/// # use prettypretty::{Color, ColorSpace};
/// let deep_purple = Color::oklch(0.5, 0.25, 308.0);
/// assert_eq!(deep_purple.space(), ColorSpace::Oklch);
/// ```
pub const fn oklch(l: f64, c: f64, h: f64) -> Self {
Color {
space: ColorSpace::Oklch,
coordinates: [l, c, h],
}
}

/// Access the color space.
///
/// ```
Expand All @@ -107,11 +154,92 @@ impl Color {
&self.coordinates
}

/// Convert this color to the given color space.
pub fn to(&self, space: ColorSpace) -> Self {
/// Convert this color to the target color space.
///
///
/// # Challenge
///
/// A color space is usually defined through a conversion from and to
/// another color space. The color module includes handwritten functions
/// that implement just those single-hop conversions. The basic challenge
/// for arbitrary conversions, as implemented by this method, is to find a
/// path through the graph of single-hop conversions. Dijkstra's algorithm
/// would certainly work also is too general because the graph really is a
/// tree rooted in XYZ D65 and edges only have unit weights. But even an
/// optimized version would repeatedly find the same path through a rather
/// small graph. It currently has seven nodes and is unlikely to grow beyond
/// twice that size.
///
///
/// # Algorithm
///
/// Instead, this method implements the following algorithm, which requires
/// a few more handwritten functions, but avoids most of the overhead of
/// dynamic routing:
///
/// 1. If the current color space *is* the target color space, simply
/// return the coordinates.
/// 2. Handle all single-hop conversions that do not involve the root XYZ
/// D65. Since the gamma curve for sRGB and Display P3 is the same,
/// there really are only four conversions:
///
/// 1. From sRGB to Linear sRGB, from Display P3 to Linear Display P3;
/// 2. The inverse from linear to gamma-corrected coordinates;
/// 3. From Oklab to Oklch;
/// 4. From Oklch to Oklab.
///
/// 3. With same hop and same branch conversions taken care of, we know
/// that the current and target color spaces are on separate branches,
/// with one of the two color spaces possibly XYZ itself. As a result,
/// all remaining conversions can be broken down into two simpler
/// conversions:
///
/// 1. Along one branch from the current color space to the root XYZ;
/// 2. Along another branch from the root XYZ to the target color space.
///
/// By breaking conversions that go through XYZ into two steps, the
/// conversion algorithm limits the number of hops that need to be supported
/// by handwritten functions to two hops (currently). Furthermore,
/// implementing these two-hop conversion functions by composing single-hop
/// conversions is trivial. Altogether, the implementation relies on 10
/// single-hop and 6 dual-hop conversions.
///
///
/// # Trade-Offs
///
/// The above algorithm represents a compromise between full specialization
/// and entirely dynamic routing. Full specialization would require
/// (7-1)(7-1) = 36 conversion functions, some of them spanning four hops.
/// Its implementation would also require 7*7 = 49 matches on color space
/// identifiers because it needs to look up the target color space *for
/// each* source color space.
///
/// In contrast, dynamic routing gets by with the 10 single-hop conversions
/// but its implementation needs to recompute paths of up to 4 hops over and
/// over again.
///
/// Meanwhile, the above algorithm requires 6 additional dual-hop
/// conversions. Its implementation comprises 6 matches on pairs, 7 matches
/// on the source color space, and 7 matches on the target color space, *in
/// series*, to a total of 20 matches. That's also the maximum number of
/// matches it performs, which is 6 more than the fully specialized case. At
/// the same time, its implementation requires much less machinery than the
/// fully specialized one.
///
/// Now, if branches were deeper, say, we also supported HSL, HSV, and HWB
/// (with sRGB converting to HSL then HSV then HWB), the above algorithm
/// would require three-, four-, and five-hop conversions as well, which
/// would be cumbersome to implement. However, the general divide and
/// conquer approach would apply to such long branches as well. For example,
/// HSL could serve as midpoint. All other color spaces on the same branch
/// are within two hops, with exception of XYZ, which requires three (i.e.,
/// two three-hop conversion functions). In short, by performing *limited*
/// dynamic look-ups, we can get most of the benefits of a fully specialized
/// implementation.
pub fn to(&self, target: ColorSpace) -> Self {
Self {
space,
coordinates: convert(self.space, space, &self.coordinates),
space: target,
coordinates: convert(self.space, target, &self.coordinates),
}
}

Expand Down Expand Up @@ -161,9 +289,19 @@ impl Color {
/// This method uses the [CSS Color 4
/// algorithm](https://drafts.csswg.org/css-color/#css-gamut-mapping) for
/// gamut mapping. It performs a binary search in Oklch for a color with
/// less chroma that is just barely within gamut. It measures color
/// difference as the Euclidian distance in Oklab. The result has the same
/// color space as this color.
/// less chroma than the original (but the same lightness and hue), whose
/// clipped version is within the *just noticeable difference* and in gamut
/// for the current color space. That clipped color is the result.
///
/// The algorithm nicely illustrates how different color spaces are best
/// suited to different needs. First, it performs clipping and in-gamut
/// testing in the current color space. After all, that's the color space
/// the application requires the color to be in. Second, it performs color
/// adjustments in Oklch. It is eminently suited to color manipulation
/// because it is both perceptually uniform and has polar coordinates.
/// Third, it measures distance in Oklab. Since the color space is
/// perceptually uniform and has Cartesian coordinates, Euclidian distance
/// is easy to compute and still accurate.
///
///
/// # Example
Expand Down Expand Up @@ -216,14 +354,19 @@ impl std::str::FromStr for Color {

/// Instantiate a color from its string representation.
///
/// This method recognizes the following color formats:
/// This method recognizes two hexadecimal notations for RGB colors, the
/// hashed notation familiar from the web and an older notation used by X
/// Windows. Even though the latter is intended to represent *device RGB*,
/// this crate maps both to sRGB.
///
/// * The hashed hexadecimal format familiar from the web, e.g., `#0f0` or
/// `#00ff00`
/// * The X Windows hexadecimal format, e.g., `rgb:<hex>/<hex>/<hex>`.
/// The hashed notation has three or six hexadecimal digits, e.g., `#123` or
/// #`cafe00`. Note that the three digit version is a short form of the six
/// digit version with every digit repeated. In other words, the red
/// coordinate in `#123` is not 0x1/0xf but 0x11/0xff.
///
/// For the X Windows hexadecimal format, between 1 and 4 digits may be used
/// per coordinate. Final values are appropriately scaled into the unit range.
/// The X Windows notation has between one and four hexadecimal digits per
/// coordinate, e.g., `rgb:1/00/cafe`. Here, every coordinate is scaled,
/// i.e., the red coordinate in the example is 0x1/0xf.
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse(s).map(|(space, coordinates)| Color { space, coordinates })
}
Expand All @@ -233,6 +376,8 @@ impl std::str::FromStr for Color {

impl std::hash::Hash for Color {
/// Hash this color.
///
/// See [`Color`] for an overview of equality testing and hashing.
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.space.hash(state);

Expand All @@ -246,8 +391,11 @@ impl std::hash::Hash for Color {
impl PartialEq for Color {
/// Determine whether this color equals the other color.
///
/// The following equalities illustrate how normalization handles
/// not-a-numbers, very small numbers, and polar coordinates:
/// As discussed in the overview for [`Color`], [`Self::eq`] and
/// [`Self::hash`](struct.Color.html#method.hash) normalize color
/// coordinates before testing/hashing them. The following *equalities*
/// illustrate how normalization handles not-a-numbers, very small numbers,
/// and polar coordinates:
///
/// ```
/// # use prettypretty::{Color, ColorSpace};
Expand Down
Loading

0 comments on commit 77a437f

Please sign in to comment.