lib/color/xyz.rb



# frozen_string_literal: true

##
# A \Color object for the CIE 1931 \XYZ color space derived from the original CIE \RGB
# color space as linear transformation functions x̅(λ), y̅(λ), and z̅(λ) that describe the
# device-independent \CIE standard observer. It underpins most other CIE color systems
# (such as \CIELAB), but is directly used mostly for color instrument readings and color
# space transformations particularly in color profiles.
#
# The \XYZ color space ranges describe the mixture of wavelengths of light required to
# stimulate cone cells in the human eye, as well as the luminance (brightness) required.
# The `Y` component describes the luminance while the `X` and `Z` components describe two
# axes of chromaticity. Definitionally, the minimum value for any \XYZ color component is
# 0.
#
# As \XYZ describes imaginary colors, the color gamut is usually expressed in relation to
# a reference white of an illuminant (frequently often D65 or D50) and expressed as the
# `xyY` color space, computed as:
#
# ```
# x = X / (X + Y + Z)
# y = Y / (X + Y + Z)
# Y = Y
# ```
#
# The range of `Y` values is conventionally clamped to 0..100, whereas the `X` and `Z`
# values must be no lower than 0 and on the same scale.
#
# For more details, see [CIE XYZ color space][ciexyz].
#
# [ciexyz]: https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_XYZ_color_space
#
# \XYZ colors are immutable Data class instances. Array deconstruction is `[x * 100,
# y * 100, z * 100]` and hash deconstruction is `{x:, y:, z:}` (see #x, #y, #z).
class Color::XYZ
  include Color

  ##
  # :attr_reader: x
  # The X attribute of this \XYZ color object expressed as a value scaled to #y.

  ##
  # :attr_reader: y
  # The Y attribute of this \XYZ color object expressed as a value 0..1.

  ##
  # :attr_reader: z
  # The Z attribute of this \XYZ color object expressed as a value scaled to #y.

  ##
  # Creates a \XYZ color representation from native values. `y` must be between 0 and 100
  # and `x` and `z` values must be scaled to `y`.
  #
  # ```ruby
  # Color::XYZ.from_values(95.047, 100.00, 108.883)
  # Color::XYZ.from_values(x: 95.047, y: 100.00, z: 108.883)
  # ```
  #
  # call-seq:
  #   Color::XYZ.from_values(x, y, z)
  #   Color::XYZ.from_values(x:, y:, z:)
  def self.from_values(*args, **kwargs)
    x, y, z =
      case [args, kwargs]
      in [[_, _, _], {}]
        args
      in [[], {x:, y:, z:}]
        [x, y, z]
      else
        new(*args, **kwargs)
      end

    new(x: x / 100.0, y: y / 100.0, z: z / 100.0)
  end

  class << self
    alias_method :from_fraction, :new
    alias_method :from_internal, :new # :nodoc:
  end

  ##
  # Creates a \XYZ color representation from native values. The `y` value must be between
  # 0 and 1 and `x` and `z` must be fractional valiues greater than or equal to 0.
  #
  # ```ruby
  # Color::XYZ.from_fraction(0.95047, 1.0, 1.0883)
  # Color::XYZ.new(0.95047, 1.0, 1.08883)
  # Color::XYZ[x: 0.95047, y: 1.0, z: 1.08883]
  # ```
  def initialize(x:, y:, z:)
    # The X and Z values in the XYZ color model are technically unbounded. With Y scaled
    # to 1.0, we will clamp X to 0.0..2.2 and Z to 0.0..2.8.

    super(
      x: normalize(x, 0.0..2.2),
      y: normalize(y),
      z: normalize(z, 0.0..2.8)
    )
  end

  # :stopdoc:
  # NOTE: This should be using Rational instead of floating point values,
  # otherwise there will be discontinuities.
  # http://www.brucelindbloom.com/LContinuity.html
  # :startdoc:

  E = 216r/24389r # :nodoc:
  K = 24389r/27r # :nodoc:
  EK = E * K # :nodoc:

  ##
  # White points for standard illuminants at 2° (CIE 1931).
  WP2 = {
    A: new(1.09849161234507, 1.0, 0.355798257454902),
    B: new(0.9909274480248, 1.0, 0.853132732288615),
    C: new(0.980705971659919, 1.0, 1.18224949392713),
    D50: new(0.964211994421199, 1.0, 0.825188284518828),
    D55: new(0.956797052643698, 1.0, 0.921480586017327),
    D65: new(0.950430051970945, 1.0, 1.08880649180926),
    D75: new(0.949722089884072, 1.0, 1.22639352072415),
    D93: new(0.953014035205816, 1.0, 1.41274275520851),
    E: new(1, 1.0, 1.0000300003),
    F1: new(0.92833634773327, 1.0, 1.03664719660806),
    F2: new(0.991446614618029, 1.0, 0.673159423379253),
    F3: new(1.03753487192493, 1.0, 0.49860512300279),
    F4: new(1.0914726375561, 1.0, 0.388132609288601),
    F5: new(0.908719701138108, 1.0, 0.987228866815325),
    F6: new(0.973091283635895, 1.0, 0.601905497618128),
    F7: new(0.950171560440895, 1.0, 1.08629642000425),
    F8: new(0.96412543554007, 1.0, 0.823331010452962),
    F9: new(1.00364797081623, 1.0, 0.678683511708377),
    F10: new(0.961735119213027, 1.0, 0.817123325737787),
    F11: new(1.00898894280487, 1.0, 0.642616604353936),
    F12: new(1.08046289656537, 1.0, 0.392275166291635),
    "FL3.0": new(1.09273493677163, 1.0, 0.3868088271758),
    "FL3.1": new(1.01981788966256, 1.0, 0.658275307980718),
    "FL3.2": new(0.916836289619075, 1.0, 0.990985751671998),
    "FL3.3": new(1.09547365817462, 1.0, 0.377937175364828),
    "FL3.4": new(1.02096949891068, 1.0, 0.702342047930283),
    "FL3.5": new(0.968888888888889, 1.0, 0.808888888888889),
    "FL3.6": new(1.08380716934487, 1.0, 0.388380716934487),
    "FL3.7": new(0.996868475991649, 1.0, 0.612734864300626),
    "FL3.8": new(0.974380395433027, 1.0, 0.810359231411863),
    "FL3.9": new(0.970505617977528, 1.0, 0.838483146067416),
    "FL3.10": new(0.944962143273151, 1.0, 0.967093768200349),
    "FL3.11": new(1.08422095615556, 1.0, 0.392865989596235),
    "FL3.12": new(1.02846401718582, 1.0, 0.656820622986037),
    "FL3.13": new(0.955112219451372, 1.0, 0.815738431698531),
    "FL3.14": new(0.951034063260341, 1.0, 1.09032846715328),
    HP1: new(1.28433734939759, 1.0, 0.125301204819277),
    HP2: new(1.14911014911015, 1.0, 0.255892255892256),
    HP3: new(1.05570552147239, 1.0, 0.398282208588957),
    HP4: new(1.00395048722676, 1.0, 0.62970766394522),
    HP5: new(1.01696741179639, 1.0, 0.676272555884729),
    "LED-B1": new(1.11819519372241, 1.0, 0.3339872486513),
    "LED-B2": new(1.08599202392822, 1.0, 0.406530408773679),
    "LED-B3": new(1.0088638195004, 1.0, 0.677142089712597),
    "LED-B4": new(0.977155910908053, 1.0, 0.87835522558538),
    "LED-B5": new(0.963535228677379, 1.0, 1.12669962917182),
    "LED-BH1": new(1.10034431874078, 1.0, 0.359075258239056),
    "LED-RGB1": new(1.08216575635241, 1.0, 0.292567086202802),
    "LED-V1": new(1.12462908011869, 1.0, 0.348170128585559),
    "LED-V2": new(1.00158940397351, 1.0, 0.647417218543046),
    ID50: new(0.952803997779012, 1.0, 0.823431426985008),
    ID65: new(0.939522225582099, 1.0, 1.08436649531297)
  }.freeze

  ##
  # The D50 standard illuminant white point at 2° (CIE 1931).
  D50 = WP2[:D50]

  ##
  # The D65 standard illuminant white point at 2° (CIE 1931).
  D65 = WP2[:D65]

  ##
  # Coerces the other Color object into \XYZ.
  def coerce(other) = other.to_xyz

  ##
  def to_xyz(...) = self

  ##
  # Converts \XYZ to Color::CMYK via Color::RGB.
  #
  # See #to_rgb and Color::RGB#to_cmyk.
  def to_cmyk(...) = to_rgb(...).to_cmyk(...)

  ##
  # Converts \XYZ to Color::Grayscale using the #y value
  def to_grayscale(...) = Color::Grayscale.from_fraction(y)

  ##
  # Converts \XYZ to Color::HSL via Color::RGB.
  #
  # See #to_rgb and Color::RGB#to_hsl.
  def to_hsl(...) = to_rgb(...).to_hsl(...)

  ##
  # Converts \XYZ to Color::YIQ via Color::RGB.
  #
  # See #to_rgb and Color::RGB#to_yiq.
  def to_yiq(...) = to_rgb(...).to_yiq(...)

  ##
  # Converts \XYZ to Color::CIELAB.
  #
  # :call-seq:
  #   to_lab(white: Color::XYZ::D65)
  def to_lab(*args, **kwargs)
    ref = kwargs[:white] || args.first || Color::XYZ::D65
    # Calculate the ratio of the XYZ values to the reference white.
    # http://www.brucelindbloom.com/index.html?Equations.html
    rel = scale(1.0 / ref.x, 1.0 / ref.y, 1.0 / ref.z)

    # And now transform
    # http://en.wikipedia.org/wiki/Lab_color_space#Forward_transformation
    # There is a brief explanation there as far as the nature of the calculations,
    # as well as a much nicer looking modeling of the algebra.
    f = rel.map { |t|
      if t > E
        t**(1.0 / 3)
      else # t <= E
        ((K * t) + 16) / 116.0
        # The 4/29 here is for when t = 0 (black). 4/29 * 116 = 16, and 16 -
        # 16 = 0, which is the correct value for L* with black.
        #       ((1.0/3)*((29.0/6)**2) * t) + (4.0/29)
      end
    }
    Color::CIELAB.from_values(
      (116 * f.y) - 16,
      500 * (f.x - f.y),
      200 * (f.y - f.z)
    )
  end

  ##
  # Converts \XYZ to Color::RGB.
  #
  # This always assumes an sRGB target color space and a D65 white point.
  def to_rgb(...)
    # sRGB companding from linear values
    linear = [
      x * 3.2406255 + y * -1.5372080 + z * -0.4986286,
      x * -0.9689307 + y * 1.8757561 + z * 0.0415175,
      x * 0.0557101 + y * -0.2040211 + z * 1.0569959
    ].map {
      if _1.abs <= 0.0031308
        _1 * 12.92
      else
        1.055 * (_1**(1 / 2.4)) - 0.055
      end
    }

    Color::RGB.from_fraction(*linear)
  end

  def deconstruct = [x * 100.0, y * 100.0, z * 100.0] # :nodoc:
  alias_method :to_a, :deconstruct # :nodoc:

  def to_internal = [x, y, z] # :nodoc:

  def inspect = "XYZ [#{x} #{y} #{z}]" # :nodoc:

  def pretty_print(q) # :nodoc:
    q.text "XYZ"
    q.breakable
    q.group 2, "[", "]" do
      q.text "%.4f" % x
      q.fill_breakable
      q.text "%.4f" % y
      q.fill_breakable
      q.text "%.4f" % z
    end
  end
end