Repository: facelessuser/coloraide (language: python)

Issue:
Pointer gamut
Over on Color.js someone brought up the idea of adding a way to test if a color was within the Pointer gamut. This can be of interest to some people, and probably worth considering. I went ahead and created a POC. Data for the Pointer gamut is found here: https://www.rit-mcsl.org/UsefulData/PointerData.xls.

Below we can test if a color is within the Pointer gamut or fit a color to the Pointer gamut.

```py
>>> Color('red').in_pointer_gamut()
False
>>> Color('red').fit_pointer_gamut().to_string()
'rgb(244 46.539 23.139)'
```

The idea is simple enough. The data provided gives discrete values for chroma at specific lightness and hues in the LCh color space using an SC illumination. We can simply reuse existing Lab and LCh logic and convert any color to this space. We simply find the closest lightness in the table and the closest hue and interpolate the max chroma for that color's specific lightness and hue. Essentially, we use the data to determine if a point is within the gamut's bounds, and if the color falls between the discrete data available, we interpolate the bounds.

![fit](https://github.com/facelessuser/coloraide/assets/1055125/f3ad0f71-f41c-4abf-993f-57604b39f6c6)

I thought about shoehorning this into existing `in_gamut()` and `fit()` logic, but I'm not sure it really belongs there. These two existing functions have a color space-specific context, and the Pointer gamut is more a gamut based on observation, not a color space. It seems it makes sense to maybe expose its own API.

The alternative would be to allow `in_gamut()` and `fit()` to have an additional constraint. The example below would fit to the sRGB gamut, but also clamp to the Pointer gamut. I think having a separate API may be more useful and intuitive.

```py
Color('red').fit('srgb', pointer=True)
```



Discussion / hints:

Fix patch:
diff --git a/coloraide/algebra.py b/coloraide/algebra.py
index af22bef6d..0a246044b 100644
--- a/coloraide/algebra.py
+++ b/coloraide/algebra.py
@@ -112,6 +112,10 @@ def round_to(f: float, p: int = 0) -> float:
     elif p == 0:
         return round_half_up(f)
 
+    # Ignore infinity
+    elif math.isinf(f):
+        return f
+
     # Round to the specified precision
     else:
         whole = int(f)
diff --git a/coloraide/color.py b/coloraide/color.py
index 13d809fc2..87f5e8e96 100644
--- a/coloraide/color.py
+++ b/coloraide/color.py
@@ -4,6 +4,7 @@
 import functools
 import random
 import math
+from . import cat
 from . import distance
 from . import convert
 from . import gamut
@@ -69,6 +70,8 @@
 from .types import Plugin
 from typing import overload, Sequence, Iterable, Any, Callable, Mapping
 
+SUPPORTED_CHROMATICITY_SPACES = set(('xyz', 'uv-1960', 'uv-1976', 'xy-1931'))
+
 
 class ColorMatch:
     """Color match object."""
@@ -477,11 +480,12 @@ def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None =
     @classmethod
     def blackbody(
         cls,
+        space: str,
         temp: float,
         duv: float = 0.0,
         *,
-        space: str | None = 'srgb-linear',
-        out_space: str | None = None,
+        scale: bool = True,
+        scale_space: str | None = None,
         method: str | None = None,
         **kwargs: Any
     ) -> Color:
@@ -499,29 +503,7 @@ def blackbody(
         """
 
         cct = temperature.cct(method, cls)
-
-        if out_space is None:
-            out_space = space or 'xyz-d65'
-
-        color = cct.from_cct(cls, temp, duv, **kwargs)
-
-        # Normalize in the given RGB color space (ideally linear).
-        if space is not None and isinstance(cls.CS_MAP[space], RGBish):
-            color.convert(space, in_place=True)
-            coords = color.coords()
-
-            # Add just enough white to make r, g, b all positive.
-            w = -min(0.0, *coords)
-            if w:
-                coords = [c + w for c in coords]
-
-            # Normalize values such that the maximum is 1 (unless all are zero)
-            m = max(coords)
-            color[:-1] = [c / m for c in coords] if m else coords
-
-        if out_space != color.space():
-            color.convert(out_space, in_place=True)
-
+        color = cct.from_cct(cls, space, temp, duv, scale, scale_space, **kwargs)
         return color
 
     def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector:
@@ -678,38 +660,178 @@ def __repr__(self) -> str:
 
     __str__ = __repr__
 
-    def white(self) -> Vector:
+    def white(self, cspace: str = 'xyz') -> Vector:
         """Get the white point."""
 
-        return util.xy_to_xyz(self._space.white())
+        value = self.convert_chromaticity('xy-1931', cspace, self._space.WHITE)
+        return value if cspace == 'xyz' else value[:-1]
 
-    def uv(self, mode: str = '1976') -> Vector:
+    def uv(self, mode: str = '1976', *, white: VectorLike | None = None) -> Vector:
         """Convert to `xy`."""
 
-        if mode == '1976':
-            uv = util.xy_to_uv(self.xy())
-        elif mode == '1960':
-            uv = util.xy_to_uv_1960(self.xy())
-        else:
-            raise ValueError("'mode' must be either '1960' or '1976' (default), not '{}'".format(mode))
-        return uv
+        return self.split_chromaticity('uv-' + mode)[:-1]
 
-    def xy(self) -> Vector:
+    def xy(self, *, white: VectorLike | None = None) -> Vector:
         """Convert to `xy`."""
 
+        return self.split_chromaticity('xy-1931')[:-1]
+
+    def split_chromaticity(
+        self,
+        cspace: str = 'uv-1976',
+        *,
+        white: VectorLike | None = None
+    ) -> Vector:
+        """
+        Split a color into chromaticity and luminance coordinates.
+
+        Colors are split under the XYZ color space using the current color's white point.
+        If results are desired relative to a different white point, one can be provided.
+        """
+
+        if white is None:
+            white = self._space.WHITE
+
+        # Convert to XYZ D65 as it is a color space that is always required.
+        # Chromatically adapt it to the XYZ color space with the current color's white point.
         xyz = self.convert('xyz-d65')
         coords = self.chromatic_adaptation(
             xyz._space.WHITE,
-            self._space.WHITE,
+            white,
             xyz.coords(nans=False)
         )
-        return util.xyz_to_xyY(coords, self._space.white())[:2]
+
+        # XYZ is not a chromaticity space
+        if cspace == 'xyz':
+            raise ValueError('XYZ is not a luminant-chromaticity color space.')
+
+        # Convert to the the requested uv color space if required.
+        return (
+            self.convert_chromaticity('xyz', cspace, coords, white=white) if cspace != 'xy_1931' else coords
+        )
+
+    @classmethod
+    def chromaticity(
+        cls,
+        space: str,
+        coords: VectorLike,
+        cspace: str = 'uv-1976',
+        *,
+        scale: bool = False,
+        scale_space: str | None = None,
+        white: VectorLike | None = None
+    ) -> Color:
+        """
+        Create a color from chromaticity coordinates.
+
+        A luminance of 1 will be assumed unless luminance is included with the coordinates.
+        The relative white point of the chromaticity coordinates will be assumed as the
+        targeted color space unless one is provided via `white`.
+
+        Lastly, colors can be scaled/normalized within a linear RGB space to normalize
+        luminance and provide a nice viewable color. This is useful when the luminance is
+        not accurate (such as when luminance is assumed 1). Colors that are out of the linear
+        RGB space's gamut will only be rough approximations of the color due to gamut
+        limitations. Default linear RGB space is linear sRGB.
+        """
+
+        if scale_space is None:
+            scale_space = 'srgb-linear'
+
+        # Use the white point of the target color space unless a white point is given.
+        if white is None:
+            white = cls.CS_MAP[space].WHITE
+
+        # XYZ is not a chromaticity space
+        if cspace == 'xyz':
+            raise ValueError('XYZ is not a luminant-chromaticity color space.')
+
+        coords = cls.convert_chromaticity(cspace, 'xyz', coords, white=white)
+
+        # Apply chromatic adaptation to match XYZ D65 white point
+        color = cls(
+            'xyz-d65',
+            cls.chromatic_adaptation(white, cls.CS_MAP['xyz-d65'].WHITE, coords)
+        )
+
+        # Normalize in the given RGB color space (ideally linear).
+        if scale and isinstance(cls.CS_MAP[scale_space], RGBish):
+            color.convert(scale_space, in_place=True)
+            color[:-1] = util.rgb_scale(color.coords())
+
+        # Convert to targeted color space
+        if space != color.space():
+            color.convert(space, in_place=True)
+
+        return color
+
+    @classmethod
+    def convert_chromaticity(
+        cls, cspace1: str,
+        cspace2: str,
+        coords: VectorLike,
+        *,
+        white: VectorLike | None = None
+    ) -> Vector:
+        """
+        Convert to or from chromaticity coordinates or between other chromaticity coordinates.
+
+        When converting to or from chromaticity coordinates, the coordinates must be in the XYZ space.
+        A white point can be provided and only serves to align colors like black on the achromatic axis;
+        otherwise, black will be returned as [0, 0] for the two respective chromaticity points.
+        """
+
+        # Check that we know the requested spaces
+        if cspace1 not in SUPPORTED_CHROMATICITY_SPACES:
+            raise ValueError("Unexpected chromaticity space '{}'".format(cspace1))
+        if cspace2 not in SUPPORTED_CHROMATICITY_SPACES:
+            raise ValueError("Unexpected chromaticity space '{}'".format(cspace2))
+
+        # Return if there is nothing to convert
+        l = len(coords)
+        if (cspace1 == 'xyz' and l != 3) or l not in (2, 3):
+            raise ValueError('Unexpected number of coordinates ({}) for {}'.format(l, cspace1))
+
+        # Return if already in desired form
+        if cspace1 == cspace2:
+            return list(coords) + [1] if l == 2 else list(coords)
+
+        # If starting space is XYZ, then convert to xy
+        if cspace1 == 'xyz':
+            coords = util.xyz_to_xyY(coords, [0.0] * 2 if white is None else white)
+            cspace1 = 'xy-1931'
+
+            # If the end space is xy, we have nothing else to do
+            if cspace2 == cspace1:
+                return coords
+
+        # If we have no luminance, assume 1
+        pair, Y = (coords[:-1], coords[-1]) if l == 3 else (coords, 1.0)
+
+        # If we are targeting XYZ, force conversion to xy first.
+        target = cspace2
+        if cspace2 == 'xyz':
+            cspace2 = 'xy-1931'
+
+        # Perform conversion
+        if cspace1 == 'xy-1931' and cspace2 != 'xy-1931':
+            pair = util.xy_to_uv_1960(pair) if cspace2 == 'uv-1960' else util.xy_to_uv(pair)
+        elif cspace1 == 'uv-1960':
+            pair = util.uv_1960_to_xy(pair) if cspace2 == 'xy-1931' else util.xy_to_uv(util.uv_1960_to_xy(pair))
+        elif cspace1 == 'uv-1976':
+            pair = util.uv_to_xy(pair) if cspace2 == 'xy-1931' else util.xy_to_uv_1960(util.uv_to_xy(pair))
+
+        # Special case to convert to XYZ from xy
+        if target == 'xyz':
+            return util.xy_to_xyz(pair, Y)
+
+        return list(pair) + [Y]
 
     @classmethod
     def chromatic_adaptation(
         cls,
-        w1: tuple[float, float],
-        w2: tuple[float, float],
+        w1: VectorLike,
+        w2: VectorLike,
         xyz: VectorLike,
         *,
         method: str | None = None
@@ -720,7 +842,7 @@ def chromatic_adaptation(
         if not adapter:
             raise ValueError("'{}' is not a supported CAT".format(method))
 
-        return adapter.adapt(w1, w2, xyz)
+        return adapter.adapt(tuple(w1), tuple(w2), xyz)  # type: ignore[arg-type]
 
     def clip(self, space: str | None = None) -> Color:
         """Clip the color channels."""
@@ -796,6 +918,16 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_
 
         return gamut.verify(c, tolerance)
 
+    def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool:
+        """Check if in pointer gamut."""
+
+        return gamut.pointer.in_pointer_gamut(self, tolerance)
+
+    def fit_pointer_gamut(self) -> Color:
+        """Check if in pointer gamut."""
+
+        return gamut.pointer.fit_pointer_gamut(self)
+
     def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Color:
         """Mask color channels."""
 
@@ -998,10 +1130,22 @@ def closest(
 
         return distance.closest(self, colors, method=method, **kwargs)
 
-    def luminance(self) -> float:
+    def luminance(self, *, white: VectorLike = cat.WHITES['2deg']['D65']) -> float:
         """Get color's luminance."""
 
-        return self.convert("xyz-d65").get('y', nans=False)
+        if white is None:
+            white = self._space.WHITE
+
+        # Convert to XYZ D65 as it is a color space that is always required.
+        # Chromatically adapt it to the XYZ color space with the current color's white point.
+        xyz = self.convert('xyz-d65')
+        coords = self.chromatic_adaptation(
+            xyz._space.WHITE,
+            white,
+            xyz.coords(nans=False)
+        )
+
+        return coords[1]
 
     def contrast(self, color: ColorInput, method: str | None = None) -> float:
         """Compare the contrast ratio of this color and the provided color."""
diff --git a/coloraide/gamut/__init__.py b/coloraide/gamut/__init__.py
index 803e5b68c..337d9480a 100644
--- a/coloraide/gamut/__init__.py
+++ b/coloraide/gamut/__init__.py
@@ -6,10 +6,13 @@
 from ..types import Plugin
 from typing import TYPE_CHECKING, Any
 from .. import util
+from . import pointer
 
 if TYPE_CHECKING:  # pragma: no cover
     from ..color import Color
 
+__all__ = ('clip_channels', 'verify', 'Fit', 'pointer')
+
 
 def clip_channels(color: Color, nans: bool = True) -> None:
     """Clip channels."""
diff --git a/coloraide/gamut/pointer.py b/coloraide/gamut/pointer.py
new file mode 100644
index 000000000..791763506
--- /dev/null
+++ b/coloraide/gamut/pointer.py
@@ -0,0 +1,217 @@
+"""
+Handle pointer gamut.
+
+Data used for the gamut: https://www.rit-mcsl.org/UsefulData/PointerData.xls.
+"""
+from __future__ import annotations
+import math
+import bisect
+from ..spaces.lab import xyz_to_lab, lab_to_xyz
+from ..spaces.lch import lab_to_lch, lch_to_lab
+from .. import algebra as alg
+from .. import util
+from ..types import Vector
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+# White point C as defined in the Pointer data spreadsheet
+XYZ_W = (98.0722647623506, 100.0, 118.225418982695)
+WHITE_POINT_SC = tuple(util.xyz_to_xyY(XYZ_W)[:-1])  # type: tuple[float, float]  # type: ignore[assignment]
+# Rows: hue 0 - 350 at steps of 10
+# Columns: lightness 15 - 90 at steps of 5
+LCH_L = list(range(15, 91, 5))
+LCH_H = list(range(0, 351, 10))
+LCH_POINTER = [
+    [10, 30, 43, 56, 68, 77, 79, 77, 72, 65, 57, 50, 40, 30, 19, 8],
+    [15, 30, 45, 56, 64, 70, 73, 73, 71, 65, 57, 48, 39, 30, 18, 7],
+    [14, 34, 49, 61, 69, 74, 76, 76, 74, 68, 61, 51, 40, 30, 19, 9],
+    [35, 48, 59, 68, 75, 82, 84, 83, 80, 75, 67, 56, 45, 33, 21, 10],
+    [27, 40, 53, 66, 79, 90, 94, 93, 88, 82, 72, 60, 47, 35, 22, 10],
+    [10, 21, 34, 45, 60, 75, 90, 100, 102, 99, 88, 75, 59, 45, 30, 15],
+    [4, 15, 26, 37, 48, 59, 70, 82, 93, 103, 106, 98, 85, 66, 45, 23],
+    [5, 15, 25, 36, 46, 56, 67, 76, 85, 94, 102, 108, 103, 82, 58, 34],
+    [6, 15, 24, 32, 40, 48, 55, 64, 72, 82, 94, 105, 115, 115, 83, 48],
+    [4, 12, 20, 28, 36, 44, 53, 60, 68, 75, 83, 90, 98, 106, 111, 90],
+    [9, 16, 23, 30, 37, 45, 51, 58, 65, 72, 80, 86, 94, 100, 106, 108],
+    [9, 18, 27, 35, 44, 52, 59, 66, 74, 82, 87, 92, 95, 100, 96, 84],
+    [4, 14, 23, 32, 41, 49, 57, 64, 71, 78, 84, 90, 94, 95, 83, 50],
+    [5, 18, 30, 40, 48, 56, 64, 70, 77, 82, 85, 88, 89, 84, 64, 35],
+    [7, 20, 32, 42, 52, 60, 69, 76, 82, 87, 89, 90, 83, 71, 54, 30],
+    [7, 21, 34, 45, 57, 68, 75, 81, 84, 84, 83, 80, 72, 58, 44, 20],
+    [8, 24, 36, 48, 58, 68, 76, 82, 85, 83, 78, 69, 59, 49, 34, 15],
+    [13, 25, 36, 47, 57, 65, 70, 75, 76, 75, 71, 65, 57, 45, 30, 15],
+    [10, 25, 38, 48, 57, 64, 69, 71, 72, 69, 64, 60, 51, 41, 29, 16],
+    [7, 19, 30, 40, 48, 55, 59, 62, 62, 60, 55, 49, 41, 32, 23, 13],
+    [5, 19, 29, 37, 42, 45, 46, 46, 45, 43, 39, 35, 30, 22, 14, 7],
+    [0, 12, 17, 26, 34, 43, 49, 51, 54, 50, 46, 40, 32, 24, 14, 4],
+    [2, 12, 20, 28, 35, 40, 45, 48, 51, 49, 45, 38, 32, 23, 15, 6],
+    [10, 20, 29, 36, 42, 46, 49, 51, 52, 50, 45, 39, 32, 24, 15, 7],
+    [8, 16, 26, 34, 41, 47, 49, 50, 50, 47, 42, 36, 29, 21, 12, 4],
+    [9, 21, 32, 40, 49, 54, 55, 55, 52, 48, 43, 36, 29, 21, 13, 4],
+    [12, 24, 34, 41, 46, 51, 55, 56, 51, 46, 40, 33, 27, 20, 13, 6],
+    [14, 31, 42, 50, 55, 60, 60, 57, 50, 45, 39, 33, 26, 20, 13, 6],
+    [10, 29, 45, 55, 60, 61, 60, 57, 53, 46, 40, 34, 25, 18, 11, 4],
+    [20, 40, 60, 69, 71, 69, 65, 58, 50, 43, 36, 29, 24, 18, 12, 5],
+    [30, 55, 72, 81, 79, 72, 64, 57, 50, 42, 35, 30, 24, 17, 12, 5],
+    [62, 76, 85, 88, 85, 80, 71, 62, 55, 47, 41, 34, 27, 20, 14, 6],
+    [60, 71, 79, 84, 85, 86, 82, 74, 66, 57, 48, 40, 31, 24, 16, 8],
+    [20, 50, 72, 86, 89, 89, 86, 80, 72, 63, 54, 45, 36, 27, 18, 9],
+    [26, 49, 63, 73, 82, 87, 87, 83, 78, 71, 62, 51, 40, 28, 18, 4],
+    [15, 37, 52, 65, 73, 79, 82, 84, 79, 73, 63, 53, 40, 30, 17, 6]
+]
+
+
+def lch_sc_to_xyY(lch: Vector) -> Vector:
+    """Convert from LCh to xy coordinates."""
+
+    return util.xyz_to_xyY(lab_to_xyz(lch_to_lab(lch), XYZ_W), XYZ_W)
+
+
+def to_lch_sc(color: Color) -> Vector:
+    """Convert a color to LCh with an SC illuminant."""
+
+    xyz = color.convert('xyz-d65').normalize(nans=False)
+    xyz_sc = color.chromatic_adaptation(xyz._space.WHITE, WHITE_POINT_SC, xyz[:-1])
+    return lab_to_lch(xyz_to_lab(xyz_sc, util.xy_to_xyz(WHITE_POINT_SC)))
+
+
+def from_lch_sc(color: Color, lch: Vector) -> Color:
+    """Convert a color from LCh with an SC illuminant."""
+
+    xyz_sc = lab_to_xyz(lch_to_lab(lch), util.xy_to_xyz(WHITE_POINT_SC))
+    xyz_d65 = color.chromatic_adaptation(WHITE_POINT_SC, color.CS_MAP['xyz-d65'].WHITE, xyz_sc)
+    return color.update('xyz-d65', xyz_d65, color[-1])
+
+
+def closest_lightness(l: float) -> tuple[int, float]:
+    """Calculate the two closest lightness values and return the first index and interpolation factor."""
+
+    # Handle too low lightness inside tolerance
+    if l <= LCH_L[0]:
+        li = 0
+        lf = 0.0
+
+    # Handle too high lightness inside tolerance
+    elif l >= LCH_L[-1]:
+        li = len(LCH_L) - 2
+        lf = 1.0
+
+    # Handle lightness with gamut
+    else:
+        li = bisect.bisect(LCH_L, l) - 1
+        l1, l2 = LCH_L[li:li + 2]
+        lf = 1 - (l2 - l) / (l2 - l1)
+
+    return li, lf
+
+
+def closest_hue(h: float) -> tuple[int, float]:
+    """Calculate the two closest hues and return the first index and interpolation factor."""
+
+    hi = 0
+    # Handle wrapping hue
+    if h > LCH_H[-1]:
+        hi = len(LCH_H) - 1
+        h1, h2 = 350, 360
+
+    # Handle non-wrapping hue
+    else:
+        hi = bisect.bisect(LCH_H, h) - 1
+        h1, h2 = LCH_H[hi:hi + 2]
+    hf = 1 - (h2 - h) / (h2 - h1)
+
+    return hi, hf
+
+
+def get_chroma_limit(l: float, h: float) -> float:
+    """Get the chroma limit."""
+
+    # Find the two closest lightness columns and calculate the needed interpolation factor.
+    li, lf = closest_lightness(l)
+
+    # Find the two closest hue rows and calculate the needed interpolation factor.
+    hi, hf = closest_hue(h)
+
+    # Interpolate the chroma limit by interpolating chroma values for the closest lightness values and hues.
+    if hi == len(LCH_H) - 1:
+        row1, row2 = LCH_POINTER[-1], LCH_POINTER[0]
+    else:
+        row1, row2 = LCH_POINTER[hi:hi + 2]
+    return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf)
+
+
+def fit_pointer_gamut(color: Color) -> Color:
+    """Fit a color to the Pointer gamut."""
+
+    # Convert to CIE LCh with the SC illuminant
+    l, c, h = to_lch_sc(color)
+
+    # Clamp lightness
+    new_l = max(LCH_L[0], l)
+    new_l = min(LCH_L[-1], new_l)
+
+    new_c = min(c, get_chroma_limit(l, h))
+
+    adjusted = l != new_l or c != new_c
+
+    # Adjust original color only if a modification was made
+    return from_lch_sc(color, [new_l, new_c, h]) if adjusted else color
+
+
+def in_pointer_gamut(color: Color, tolerance: float) -> bool:
+    """
+    See if color is within the pointer gamut.
+
+    Convert to CIE LCh using the SC illuminant.
+    Find the closest hues and lightness (rows and columns) so we can interpolate
+    the an appropriate max chroma for a given hue and lightness. Test that the
+    color's chroma does not exceed the limit.
+    """
+
+    # Convert to CIE LCh with the SC illuminant
+    l, c, h = to_lch_sc(color)
+
+    # If lightness exceeds the acceptable range, then we are not in gamut
+    if (l < (LCH_L[0] - tolerance)) or (l > (LCH_L[-1] + tolerance)):
+        return False
+
+    # Test that the color does not exceed the max chroma
+    return c <= (get_chroma_limit(l, h) + tolerance)
+
+
+def pointer_gamut_boundary(lightness: float | None = None) -> list[Vector]:
+    """
+    Calculate the Pointer gamut boundary points for the given lightness.
+
+    If no lightness is provided, calculate the maximum pointer boundary.
+    Result is returned as xyY coordinates (C illuminant).
+    """
+
+    # Maximum Pointer gamut boundary
+    # For each hue, find the lightness/chroma point that is furthest away from the white point.
+    if lightness is None:
+        max_gamut = []  # type: list[Vector]
+        for i, h in enumerate(LCH_H):
+            max_dxy = 0.0
+            max_xyy = [0.0, 0.0, 0.0]
+            for j, l in enumerate(LCH_L):
+                xyy = lch_sc_to_xyY([l, LCH_POINTER[i][j], h])
+                dxy = math.sqrt((WHITE_POINT_SC[0] - xyy[0]) ** 2 + (WHITE_POINT_SC[1] - xyy[1]) ** 2)
+                if dxy > max_dxy:
+                    max_dxy = dxy
+                    max_xyy = xyy
+            max_gamut.append(max_xyy)
+        return max_gamut
+
+    # Pointer gamut boundary at a given lightness
+    # Return all the points for a given lightness
+    elif LCH_L[0] <= lightness <= LCH_L[-1]:
+        li, lf = closest_lightness(lightness)
+        chroma = [alg.lerp(row[li], row[li + 1], lf) for row in LCH_POINTER]
+        return [lch_sc_to_xyY([lightness, c, h]) for c, h in zip(chroma, LCH_H)]
+
+    # Lightness exceeds threshold
+    else:
+        raise ValueError('Lightness must be between {} and {}, but was {}'.format(LCH_L[0], LCH_L[-1], lightness))
diff --git a/coloraide/spaces/xyy.py b/coloraide/spaces/xyy.py
index b142cf21d..d9f8cf89f 100644
--- a/coloraide/spaces/xyy.py
+++ b/coloraide/spaces/xyy.py
@@ -18,7 +18,7 @@ class xyY(Space):
 
     BASE = "xyz-d65"
     NAME = "xyy"
-    SERIALIZE = ("--xyy",)  # type: tuple[str, ...]
+    SERIALIZE = ("--xyy",)
     CHANNELS = (
         Channel("x", 0.0, 1.0),
         Channel("y", 0.0, 1.0),
@@ -32,8 +32,7 @@ def is_achromatic(self, coords: Vector) -> bool:
         if math.isclose(0.0, coords[-1], abs_tol=1e-4):
             return True
 
-        white = [0.3127, 0.3290]
-        for x in alg.vcross(coords[:-1], white):
+        for x in alg.vcross(coords[:-1], self.WHITE):
             if not math.isclose(0.0, x, abs_tol=1e-6):
                 return False
         return True
diff --git a/coloraide/temperature/__init__.py b/coloraide/temperature/__init__.py
index 343753a62..379256a4d 100644
--- a/coloraide/temperature/__init__.py
+++ b/coloraide/temperature/__init__.py
@@ -18,7 +18,16 @@ def to_cct(self, color: Color, **kwargs: Any) -> Vector:
         """Calculate a color's CCT."""
 
     @abstractmethod
-    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+    def from_cct(
+        self,
+        color: type[Color],
+        space: str,
+        kelvin: float,
+        duv: float,
+        scale: bool,
+        scale_space: str | None,
+        **kwargs: Any
+    ) -> Color:
         """Calculate a color that satisfies the CCT."""
 
 
diff --git a/coloraide/temperature/ohno_2013.py b/coloraide/temperature/ohno_2013.py
index 3b9f8d609..a6dc812a7 100644
--- a/coloraide/temperature/ohno_2013.py
+++ b/coloraide/temperature/ohno_2013.py
@@ -33,7 +33,8 @@ def __init__(
         self,
         cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG,
         white: VectorLike = cat.WHITES['2deg']['D65'],
-        planck_step: int = 5
+        planck_step: int = 5,
+        chromaticity: str = 'uv-1960'
     ) -> None:
         """Initialize."""
 
@@ -43,6 +44,7 @@ def __init__(
         self.cmfs = cmfs
         self.white = util.xy_to_xyz(white)
         self.planck_step = planck_step
+        self.to_uv = util.xy_to_uv_1960 if chromaticity == 'uv-1960' else util.xy_to_uv
 
         # Low temperature range
         start = 1000
@@ -54,8 +56,10 @@ def __init__(
         self.domain = []
         for r in range(count):
             k = r * inc + start
-            u, v = planck.temp_to_uv_planckian_locus(
-                k, self.cmfs, self.white, self.cmfs_start, self.cmfs_end, self.planck_step
+            u, v = self.to_uv(
+                planck.temp_to_xy_planckian_locus(
+                    k, self.cmfs, self.white, self.cmfs_start, self.cmfs_end, self.planck_step
+                )
             )
             self.domain.append(k)
             points.append([u, v])
@@ -71,8 +75,10 @@ def __init__(
         self.domain2 = []
         for r in range(count):
             k = r * inc + start
-            u, v = planck.temp_to_uv_planckian_locus(
-                k, self.cmfs, self.white, self.cmfs_start, self.cmfs_end, self.planck_step
+            u, v = self.to_uv(
+                planck.temp_to_xy_planckian_locus(
+                    k, self.cmfs, self.white, self.cmfs_start, self.cmfs_end, self.planck_step
+                )
             )
             self.domain2.append(k)
             points.append([u, v])
@@ -98,8 +104,10 @@ def __call__(self, temp: float, exact: bool = False) -> Vector:
         """Get the uv for the given temp."""
 
         if exact:
-            return planck.temp_to_uv_planckian_locus(
-                temp, self.cmfs, self.white, self.cmfs_start, self.cmfs_end, self.planck_step
+            return self.to_uv(
+                planck.temp_to_xy_planckian_locus(
+                    temp, self.cmfs, self.white, self.cmfs_start, self.cmfs_end, self.planck_step
+                )
             )
         else:
             if temp <= 20000:
@@ -137,6 +145,7 @@ class Ohno2013(CCT):
     """
 
     NAME = 'ohno-2013'
+    CHROMATICITY = 'uv-1960'
 
     def __init__(
         self,
@@ -146,7 +155,8 @@ def __init__(
     ):
         """Initialize."""
 
-        self.blackbody = BlackBodyCurve(cmfs, white, planck_step)
+        self.white = white
+        self.blackbody = BlackBodyCurve(cmfs, white, planck_step, self.CHROMATICITY)
 
     def to_cct(
         self,
@@ -160,7 +170,7 @@ def to_cct(
     ) -> Vector:
         """Calculate a color's CCT."""
 
-        u, v = color.uv('1960')
+        u, v = color.split_chromaticity(self.CHROMATICITY)[:-1]
         last = samples - 1
         index = 0
         table = []  # type: list[tuple[float, float, float, float]]
@@ -240,7 +250,16 @@ def to_cct(
 
         return [t, duv]
 
-    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+    def from_cct(
+        self,
+        color: type[Color],
+        space: str,
+        kelvin: float,
+        duv: float,
+        scale: bool,
+        scale_space: str | None,
+        **kwargs: Any
+    ) -> Color:
         """Calculate a color that satisfies the CCT using Planck's law."""
 
         u0, v0 = self.blackbody(kelvin, exact=True)
@@ -255,4 +274,4 @@ def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs
                 u0 = u0 - duv * dv
                 v0 = v0 + duv * du
 
-        return color('xyz-d65', util.xy_to_xyz(util.uv_1960_to_xy([u0, v0]), 1))
+        return color.chromaticity(space, [u0, v0, 1], self.CHROMATICITY, scale=scale, scale_space=scale_space)
diff --git a/coloraide/temperature/planck.py b/coloraide/temperature/planck.py
index 929eb3712..d72ffe4aa 100644
--- a/coloraide/temperature/planck.py
+++ b/coloraide/temperature/planck.py
@@ -22,7 +22,7 @@
 C2 = 1.4388e-2
 
 
-def temp_to_uv_planckian_locus(
+def temp_to_xy_planckian_locus(
     temp: float,
     cmfs: dict[int, tuple[float, float, float]],
     white: VectorLike,
@@ -46,4 +46,4 @@ def temp_to_uv_planckian_locus(
         y += m * cmf[1]
         z += m * cmf[2]
 
-    return util.xy_to_uv_1960(util.xyz_to_xyY([x, y, z], white)[:2])
+    return util.xyz_to_xyY([x, y, z], white)[:2]
diff --git a/coloraide/temperature/robertson_1968.py b/coloraide/temperature/robertson_1968.py
index c4dcb30e8..e6435b788 100644
--- a/coloraide/temperature/robertson_1968.py
+++ b/coloraide/temperature/robertson_1968.py
@@ -30,6 +30,7 @@ class Robertson1968(CCT):
     """Delta E plugin class."""
 
     NAME = 'robertson-1968'
+    CHROMATICITY = 'uv-1960'
 
     def __init__(
         self,
@@ -41,6 +42,7 @@ def __init__(
     ) -> None:
         """Initialize."""
 
+        self.white = white
         self.table = self.generate_table(cmfs, white, mired, sigfig, planck_step)
 
     def generate_table(
@@ -69,14 +71,15 @@ def generate_table(
 
         xyzw = util.xy_to_xyz(white)
         table = []  # type: list[tuple[float, float, float, float]]
+        to_uv = util.xy_to_uv_1960 if self.CHROMATICITY == 'uv-1960' else util.xy_to_uv
         for t in mired:
-            uv1 = planck.temp_to_uv_planckian_locus(1e6 / (t - 0.01), cmfs, xyzw, step=planck_step)
-            uv2 = planck.temp_to_uv_planckian_locus(1e6 / (t + 0.01), cmfs, xyzw, step=planck_step)
+            uv1 = to_uv(planck.temp_to_xy_planckian_locus(1e6 / (t - 0.01), cmfs, xyzw, step=planck_step))
+            uv2 = to_uv(planck.temp_to_xy_planckian_locus(1e6 / (t + 0.01), cmfs, xyzw, step=planck_step))
             if t == 0:
                 factor = 0.5
                 uv = [alg.lerp(uv1[0], uv2[0], factor), alg.lerp(uv1[1], uv2[1], factor)]
             else:
-                uv = planck.temp_to_uv_planckian_locus(1e6 / t, cmfs, xyzw, step=planck_step)
+                uv = to_uv(planck.temp_to_xy_planckian_locus(1e6 / t, cmfs, xyzw, step=planck_step))
                 d1 = math.sqrt((uv[1] - uv1[1]) ** 2 + (uv[0] - uv1[0]) ** 2)
                 d2 = math.sqrt((uv2[1] - uv[1]) ** 2 + (uv2[0] - uv[0]) ** 2)
                 factor = d1 / (d1 + d2)
@@ -100,7 +103,7 @@ def generate_table(
     def to_cct(self, color: Color, **kwargs: Any) -> Vector:
         """Calculate a color's CCT."""
 
-        u, v = color.uv('1960')
+        u, v = color.split_chromaticity(self.CHROMATICITY)[:-1]
         end = len(self.table) - 1
         slope_invert = False
 
@@ -156,7 +159,16 @@ def to_cct(self, color: Color, **kwargs: Any) -> Vector:
 
         return [temp, -duv if duv and not slope_invert else duv]
 
-    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+    def from_cct(
+        self,
+        color: type[Color],
+        space: str,
+        kelvin: float,
+        duv: float,
+        scale: bool,
+        scale_space: str | None,
+        **kwargs: Any
+    ) -> Color:
         """Calculate a color that satisfies the CCT."""
 
         # Find inverse temperature to use as index.
@@ -205,4 +217,4 @@ def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs
                     v += dv * (-duv if not slope_invert else duv)
                 break
 
-        return color('xyz-d65', util.xy_to_xyz(util.uv_1960_to_xy([u, v]), 1))
+        return color.chromaticity(space, [u, v, 1], self.CHROMATICITY, scale=scale, scale_space=scale_space)
diff --git a/coloraide/util.py b/coloraide/util.py
index e99350245..64dc62726 100644
--- a/coloraide/util.py
+++ b/coloraide/util.py
@@ -48,7 +48,19 @@ def xy_to_xyz(xy: VectorLike, Y: float = 1.0, scale: float = 1.0) -> Vector:
     """
 
     x, y = xy
-    return [0, 0, 0] if y == 0 else [(x * Y) / y, Y, (scale - x - y) * Y / y]
+    return [0.0, 0.0, 0.0] if y == 0 else [(x * Y) / y, Y, (scale - x - y) * Y / y]
+
+
+def xyz_to_xyY(xyz: VectorLike, white: VectorLike = (0.0, 0.0)) -> Vector:
+    """
+    XYZ to `xyY`.
+
+    If a white point chromaticity pair is given, black will be aligned with the achromatic axis.
+    """
+
+    x, y, z = xyz
+    d = x + y + z
+    return [white[0], white[1], y] if d == 0 else [x / d, y / d, y]
 
 
 def xy_to_uv(xy: VectorLike) -> Vector:
@@ -92,14 +104,6 @@ def uv_1960_to_xy(uv: VectorLike) -> Vector:
     return [x, y]
 
 
-def xyz_to_xyY(xyz: VectorLike, white: VectorLike) -> Vector:
-    """XYZ to `xyY`."""
-
-    x, y, z = xyz
-    d = x + y + z
-    return [white[0], white[1], y] if d == 0 else [x / d, y / d, y]
-
-
 def pq_st2084_oetf(
     values: VectorLike,
     c1: float = C1,
@@ -118,6 +122,26 @@ def pq_st2084_oetf(
     return adjusted
 
 
+def rgb_scale(vec: VectorLike) -> Vector:
+    """
+    Scale the RGB vector.
+
+    If minimum is less than zero, behaves like min/max normalization.
+    If minimum is not less than zero, behaves like maximum normalization.
+    """
+
+    # `(v - min_v)`
+    w = min(vec)
+    if w < 0.0:
+        vec = [v - w for v in vec]
+
+    # `(max_v - min_v)`
+    m = max(vec)
+
+    # `(v - min_v) / (max_v - min_v)`
+    return [v / m if m else v for v in vec]
+
+
 def pq_st2084_eotf(
     values: VectorLike,
     c1: float = C1,
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 78c3d7f42..18f07c723 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -284,15 +284,19 @@ th
 transmissive
 triadic
 trichromacy
+tristimulus
 tritan
 tritanomaly
 tritanopia
+u'v
+u'v'Y
 un
 uncomment
 uniformities
 unintuitive
 unsaturate
 uv
+uvY
 validator
 von
 webcolor
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index a0fddb2e4..0a9dbb82a 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -3,11 +3,21 @@
 ## 2.4
 
 -   **NEW**: Add Rec. 709 RGB color space.
--   **NEW**: Add the 1960 UCS color space.
+-   **NEW**: Add the 1960 UCS color space.*
 -   **NEW**: Add correlated color temperature support with new `cct()` and `blackbody()` API.
--   **NEW**: Add support Robertson 1968 and Ohno 2013 CCT plugins.
+-   **NEW**: Add support for Robertson 1968 and Ohno 2013 CCT plugins.
+-   **NEW**: Add support for determining if a color is in the Pointer Gamut and provide a way to clamp a color to the
+    gamut.
 -   **NEW**: Include CMFS: CIE 1931 2 Degree Standard Observer, CIE 1964 10 Degree Standard Observer, CIE 2015 2 Degree
     Standard Observer, and CIE 2015 10 Degree Standard Observer.
+-   **NEW**: Add `split_chromaticity()` method which will split a color into its chromaticity and luminance parts.
+-   **NEW**: Add `chromaticity()` which will create a new color from a given set of chromaticity coordinates.
+-   **NEW**: Relax `chromatic_adaptation()` type requirement of white point chromaticity inputs.
+-   **NEW**: `luminance()`, `xy()`, and `uv()` all now accept an optional white point via the `white` parameter to
+    control the white point in which the returned values are relative to. `luminance()` still defaults to D65 but will
+    use the current color's white point, like `xy()` and `uv()` if `white` is set to `None`.
+-   **NEW**: `white()` now accepts a positional parameter allowing it to output the white point of the current color
+    as various chromaticity coordinates in addition to the default XYZ coordinates.
 -   **FIX**: Fix case where deregistering all plugins with `*` was not deregistering `Filter` plugins.
 
 ## 2.3
diff --git a/docs/src/markdown/api/index.md b/docs/src/markdown/api/index.md
index 1b1d659b7..a1e08e764 100644
--- a/docs/src/markdown/api/index.md
+++ b/docs/src/markdown/api/index.md
@@ -595,7 +595,10 @@ Return
 
 ```py
 def luminance(
-    self
+    self,
+    *,
+    white: VectorLike | None = cat.WHITES['2deg']['D65']
+
 ) -> float:
     ...
 ```
@@ -606,6 +609,13 @@ Description
 -   Get the relative luminance. Relative luminance is obtained from the Y coordinate in the XYZ color space. XYZ, in
     this case, has a D65 white point.
 
+Parameters
+
+- 
+    Parameters  | Defaults           | Description
+    ----------- | ------------------ | -----------
+    `white`     | `#!py None`        | Specify the white in which to chromatically adapt the points from, if none is specified, the current color's white point is assumed.
+
 Return
 
 -   Returns an float indicating the relative luminance.
@@ -1469,7 +1479,8 @@ def blackbody(
     temp: float,
     duv: float = 0.0,
     *,
-    space: str | None = 'srgb-linear',
+    scale: bool = True,
+    scale_space: str | None = None,
     out_space: str | None = None,
     method: str | None = None,
     **kwargs: Any
@@ -1479,20 +1490,22 @@ def blackbody(
 /// define
 Description
 
--   Creates a color from a temperature in Kelvin and an optional ∆~uv~. The color will be normalized in the linear RGB
-    space provided via `space`. By default, the Ohno 2013 algorithm is used.
+-   Creates a color from a temperature in Kelvin and an optional ∆~uv~. The color will be scaled within the linear RGB
+    space specified by `scale_space` and can be disabled by setting `scale` to `#!py False`. By default, the
+    Ohno 2013 algorithm is used and can be configured via `method`.
 
 Parameters
 
 - 
-    Parameters  | Defaults             | Description
-    ----------- | -------------------- | -----------
-    `temp`      |                      | A positive temperature in Kelvin. Accepted range of temperature is based on the algorithm.
-    `duv`       | `#!py 0.0`           | An optional ∆~uv~ specifying the distance from the black body curve.
-    `space`     | `#!py 'srgb-linear'` | An RGB color space in which the returned color should be normalized in. The color space should be a linear space for best results. It can be set to `#!py None` if no normalization is desired.
-    `out_space` | `#!py None`          | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified by `space` or `xyz-d65` if `space` is `#!py None`.
-    `method`    | `#!py None`          | A string specifying the algorithm to use. By default `ohno-2013` is used.
-    `**kwargs`  |                      | Any plugin specific parameters to pass to the `blackbody` method.
+    Parameters      | Defaults             | Description
+    --------------- | -------------------- | -----------
+    `temp`          |                      | A positive temperature in Kelvin. Accepted range of temperature is based on the algorithm.
+    `duv`           | `#!py 0.0`           | An optional ∆~uv~ specifying the distance from the black body curve.
+    `scale`         | `#!py True`          | Scale the color with a linear RGB color space as defined by `scale_space`.
+    `scale_space`   | `#!py 'srgb-linear'` | If `scale` is enabled, `scale_space` defines the RGB color space in which the returned color should be scaled within. The color space should be a linear space for best results. If undefined, `srgb-linear` will be used.
+    `out_space`     | `#!py None`          | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified by `space` or `xyz-d65` if `space` is `#!py None`.
+    `method`        | `#!py None`          | A string specifying the algorithm to use. By default `ohno-2013` is used.
+    `**kwargs`      |                      | Any plugin specific parameters to pass to the `blackbody` method.
 
 Return
 
@@ -1570,7 +1583,9 @@ Return
 
 ```py
 def xy(
-    self
+    self,
+    *,
+    white: VectorLike | None = None
 ) -> Vector:
 ```
 
@@ -1579,9 +1594,17 @@ Description
 
 -   Retrieves the CIE 1931 (x, y) chromaticity coordinates for a given color.
 
+Parameters
+
+- 
+    Parameters  | Defaults           | Description
+    ----------- | ------------------ | -----------
+    `white`     | `#!py None`        | Specify the white in which to chromatically adapt the points from, if none is specified, the current color's white point is assumed.
+
 Return
 
--   Returns a tuple of CIE 1931 (x, y) chromaticity points for the given color.
+-   Returns a tuple of CIE 1931 (x, y) chromaticity points for the given color. The XYZ translation to xy will use the
+    current color's white point to ensure the values are relative to the proper white point.
 ///
 
 ## `#!py Color.uv` {#xy}
@@ -1589,7 +1612,9 @@ Return
 ```py
 def uv(
     self,
-    mode: str = '1976'
+    mode: str = '1976',
+    *,
+    white: VectorLike | None = None
 ) -> Vector:
     ...
 ```
@@ -1603,11 +1628,120 @@ Description
 Parameters
 
 - 
-    Parameters | Defaults           | Description
-    ---------- | ------------------ | -----------
-    `mode`     | `#!py3 '1976'`     | A string indicating what mode to use. `1976` refers to the (u', v') points as described by CIE 1976 UCS and `1960` describes the (u, v) points as documented by CIE 1960 UCS.
+    Parameters  | Defaults           | Description
+    ----------- | ------------------ | -----------
+    `mode`      | `#!py3 '1976'`     | A string indicating what mode to use. `1976` refers to the (u', v') points as described by CIE 1976 UCS and `1960` describes the (u, v) points as documented by CIE 1960 UCS.
+    `white`     | `#!py None`        | Specify the white in which to chromatically adapt the points from, if none is specified, the current color's white point is assumed.
+
+Return
+
+-   Returns a tuple of (u, v) -- either 1976 (u', v') or 1960 (u, v) -- chromaticity points for the given color. The XYZ
+   translation to uv will use the current color's white point to ensure the values are relative to the proper white
+   point.
+///
+
+## `#!py Color.get_chromaticity` {#get_chromaticity}
+
+```py
+def get_chromaticity(
+    self,
+    cspace: str = 'uv-1976',
+    *,
+    white: VectorLike | None = None
+) -> Vector:
+    ...
+```
+
+/// define
+Description
+
+-   Retrieves the 1931 xy, 1960 uv, or 1976 u'v' chromaticity coordinates with the luminance (Y). Coordinates are
+    returned in the format specified by `cspace` and will use the white point of the current color.
+
+Parameters
+
+- 
+    Parameters  | Defaults           | Description
+    ----------- | ------------------ | -----------
+    `cspace`    | `#!py 'uv-1976'`   | A string indicating what chromaticity space to use. `uv-1976` being the default.
+    `white`     | `#!py None`        | Specify the white in which to chromatically adapt the points from, if none is specified, the current color's white point is assumed.
 
 Return
 
--   Returns a tuple of (u, v) -- either 1976 (u', v') or 1960 (u, v) -- chromaticity points for the given color.
+-   Returns a list of chromaticity coordinates. Results will either be in [x, y, Y] for 1931 xy, [u, v, Y] for 1960 uv,
+    or [u', v', Y] for 1976 u'v'.
 ///
+
+## `#!py Color.chromaticity` {#get_chromaticity}
+
+```py
+@classmethod
+def chromaticity(
+    cls,
+    space: str,
+    coords: VectorLike,
+    cspace: str = 'uv-1976',
+    *,
+    scale: bool = False,
+    scale_space: str | None = None,
+    white: VectorLike | None = None
+) -> Color:
+    ...
+```
+
+/// define
+Description
+
+-   Returns a color that satisfies the provided chromaticity coordinates. Coordinates can be in the form 1931 xyY, 1960
+    uvY, or 1976 u'v'Y and can be configured via `cspace`. The target space to convert to should be specified via
+    `space`. Chromaticity coordinates should math the white space of the targeted space, but if they are not the white
+    point of the chromaticity coordinates can be specified with `white`.
+
+Parameters
+
+- 
+    Parameters      | Defaults             | Description
+    --------------- | -------------------- | -----------
+    `space`         |                      | Color space to chromaticities to.
+    `coords`        |                      | The chromaticity coordinates. Values can be in either 3D form (with luminance Y).
+    `cspace`        | `#!py 'uv-1976'`     | A string indicating what chromaticity space to use. `uv-1976` being the default.
+    `white`         | `#!py None`          | Specify the white in which to chromatically adapt the points from, if none is specified, the targeted color's white point is assumed.
+    `scale`         | `#!py True`          | Scale the color with a linear RGB color space as defined by `scale_space`.
+    `scale_space`   | `#!py None`          | If `scale` is enabled, `scale_space` defines the RGB color space in which the returned color should be scaled within. The color space should be a linear space for best results. If undefined, `srgb-linear` will be used.
+
+Return
+
+-   Returns a reference to a new [`Color`](#color) object that satisfies the chromaticity coordinates.
+///
+
+## `#!py Color.convert_chromaticity` {#convert_chromaticity}
+
+```py
+@classmethod
+def convert_chromaticity(
+    cls,
+    cspace1: str,
+    cspace2: str,
+    coords: VectorLike
+) -> Vector:
+    ...
+```
+
+/// define
+Description
+
+-   Converts a 2D chromaticity pair from one chromaticity space to another. Supported spaces are `xy-1931`, `uv-1960`,
+    and `uv-1976`.
+
+Parameters
+
+- 
+    Parameters  | Defaults           | Description
+    ----------- | ------------------ | -----------
+    `cspace1`   |                    | Initial chromaticity space for the given coordinates.
+    `cspace2`   |                    | Target chromaticity space for the given coordinates.
+    `coords`    |                    | The 2D chromaticity coordinates to convert.
+
+Return
+
+-   Returns the converted 2D chromaticity coordinates.
diff --git a/docs/src/markdown/cat.md b/docs/src/markdown/cat.md
index 706dc0ba8..7cc7b796c 100644
--- a/docs/src/markdown/cat.md
+++ b/docs/src/markdown/cat.md
@@ -17,41 +17,82 @@ D50 illuminant (direct sunlight at noon).
 
 ![White Point Compare](images/whitepoint-compare.png)
 
-When translating a color from one illuminant to another, it is often desirable to represent that color under the new
-illuminant such that it appears to the eye the same as it did under the original illuminant. CATs are used to predict
+When translating a color from one illuminant to another, it is desirable to ensure that the color under the original
+illuminant appears as it should under the new illuminant, just as it would in real life. CATs are used to predict
 what the new color under the new illuminant should be in order to fulfill these requirements.
 
-For a quick example, we can demonstrate the basic principle when translating a color in the XYZ color space from a D50
-illuminant to a D65 illuminant. Below, we can see that the colors look pretty much the same, even though they are now
-described under different illuminants.
+For a quick example, let's take the color `#!color blue` under sRGB (D65 white point) and the same `#!color blue` under
+Pro Photo RGB (D50 white point). If we take the raw chromaticity points from the color under each color space and use
+them to generate a color, both under the same color space (in this case sRGB), we can see that the values are different.
+We can see the values are different.
 
 ```py play
-d50 = Color('color(xyz-d50 0.11627 0.07261 0.23256 / 1)')
-d65 = d50.convert('xyz-d65')
-d50, d65
+d65 = Color('blue').split_chromaticity()
+d50 = Color('blue').convert('prophoto-rgb').split_chromaticity()
+color_d50 = Color.chromaticity('srgb', d50)
+color_d65 = Color.chromaticity('srgb', d65)
+Row([color_d50, color_d65])
+```
+
+The same color looks different because it is reflecting a different light source. The illuminant of a color space can
+affect how the color appears, and each illuminant has a different color temperature which can provide a warmer or cooler
+color tone to the colors under a particular color space.
+
+We can visualize this concept a bit more clearly by taking the raw chromaticities from the D50 and D65 `#!color white`
+and scaling them both under the same color space. Here we will take both the D50 and D65 white point and first scale
+them under the D65 sRGB color space and then scale them under the D50 Pro Photo color space. Notice that the D50 white
+(Pro Photo) has a red shift when rendered under sRGB, but the D65 white (sRGB) has a blue shift under Pro Photo.
+Relative to each other, the D65 white has a cooler temperature than D50, and this changes the color.
+
+```py play
+d65 = Color('srgb', [1, 1, 1]).split_chromaticity()
+d50 = Color('prophoto-rgb', [1, 1, 1]).split_chromaticity()
+color_d50 = Color.chromaticity('srgb', d50, scale=True)
+color_d65 = Color.chromaticity('srgb', d65, scale=True)
+Row([color_d50, color_d65])
+color_d50 = Color.chromaticity('prophoto-rgb', d50, scale=True)
+color_d65 = Color.chromaticity('prophoto-rgb', d65, scale=True)
+Row([color_d50, color_d65])
+```
+
+In order to account for the differences in illuminants, we use chromatic adaptation to modify the chromaticities of the
+color so that that they account for the different illuminant and appear as they should under the new light source. This
+happens automatically when we do call `convert()`. We can see that the white point gets adjusted such that the D50 white
+looks like the D65 white when in sRGB and D65 white looks like D50 white under Pro Photo.
+
+```py play
+color_d50 = Color('prophoto-rgb', [1, 1, 1]).convert('srgb')
+color_d65 = Color('srgb', [1, 1, 1])
+Row([color_d50, color_d65])
 ```
 
-These transforms are usually designed for the XYZ color space as it operates in linear light making it the ideal place
-to apply the transform. Any color that must go through a CAT to account for differences in illuminants must pass through
-the XYZ color space. More specifically, ColorAide requires the color to pass through XYZ D65 space as that will trigger
-the chromatic adaptation. For instance, if a color space such as ProPhoto is being translated to sRGB, ProPhoto will
-first be transformed to XYZ D50, then XYZ D65 which will trigger the chromatic adaptation, next to Linear sRGB, and
-lastly sRGB.
+Generally, chromatic adaptation takes place within the XYZ color space. So in ColorAide, any color transform that must
+account for the differences of illuminants between two color spaces must go through chromatic adaptation, and it must
+occur in the XYZ color space. ColorAide satisfies this by making the registration of the XYZ D65 color space mandatory
+and using it as the transition color space when chromatic adaptation is needed.
+
+For instance, if a color space such as Pro Photo is being translated to sRGB, Pro Photo will first be transformed to XYZ
+D50, then it will be chromatically adapted to XYZ D65, next it will be transformed sRGB.
 
-So, we can actually do this manually and compare the results to what we did above. In order to do this, we need to
-provide the specified "white point" for the source color and the "white point" for the destination color along with the
-XYZ coordinates we wish to transform. ColorAide uses the Bradford CAT by default, so we will specify that CAT for
-consistency.
+So, we can actually do this manually and compare the results to `convert()` which automatically handles chromatic
+adaptation. In order to do this, we need to provide the specified "white point" for the source color and the "white
+point" for the destination color along with the XYZ coordinates we wish to transform. ColorAide uses the Bradford CAT
+by default, so we will specify that CAT for consistency.
 
 ```py play
 from coloraide import cat
-Color('color(xyz-d50 0.11627 0.07261 0.23256 / 1)').convert('xyz-d65')[:-1]
-Color.chromatic_adaptation(cat.WHITES['2deg']["D50"], cat.WHITES['2deg']["D65"], [0.11627, 0.07261, 0.23256], method='bradford')
+xyzd50 = Color('prophoto-rgb', [1, 1, 1]).convert('xyz-d50').coords()
+xyzd50
+xyzd65 = Color.chromatic_adaptation(cat.WHITES['2deg']["D50"], cat.WHITES['2deg']["D65"], xyzd50, method='bradford')
+manual = Color('xyz-d65', xyzd65).convert('srgb')
+auto = Color('prophoto-rgb', [1, 1, 1]).convert('srgb')
+manual, auto
 ```
 
 ColorAide, currently defines the following illuminants for both 2˚ observer and 10˚ observer, but most people are
 probably only concerned with D65 and D50 (2˚ degree observer) which are the only the illuminants used in the default
-color spaces provided by ColorAide.
+color spaces provided by ColorAide. Illuminants are not restricted to what is listed below, but those are the ones
+available by default.
 
 Illuminants |
 ----------- |
diff --git a/docs/src/markdown/chromaticity.md b/docs/src/markdown/chromaticity.md
new file mode 100644
index 000000000..1d350ce47
--- /dev/null
+++ b/docs/src/markdown/chromaticity.md
@@ -0,0 +1,207 @@
+# Chromaticity Coordinates
+
+Colors are generally composed of two parts, luminance and chromaticity. Luminance refers to the brightness while
+chromaticity refers to the hue and colorfulness.
+
+Separating out chromaticity from luminance, we can create a 2D from the chromaticity where we are able to plot the full
+spectrum of visible color. Over time, there have been multiple approaches to expressing chromaticity, the most common
+being: CIE 1931, CIE 1964, or CIE 1976.
+
+/// tab | 1931 xy Chromaticity Diagram
+![1931 xy Diagram](images/1931-xy.png)
+///
+
+/// tab | 1960 uv Chromaticity Diagram
+![1960 uv Diagram](images/1960-uv.png)
+///
+
+/// tab | 1931 u'v' Chromaticity Diagram
+![1976 u'v' Diagram](images/1976-uv.png)
+///
+
+Chromaticity coordinates are an important part of color science and are often used to define characteristics of color
+spaces, including gamuts and white points. This is often why depictions of white points and gamuts are overlaid onto
+chromaticity diagrams.
+
+![sRGB Gamut](images/srgb.png)
+
+When combined with luminance, we can add depth when viewing a gamut within the chromaticity space.
+
+![sRGB Gamut 3D](images/xyy-3d.png)
+
+## Getting Chromaticity Coordinates
+
+ColorAide provides a few  ways to access chromaticity. The first method, `split_chromaticity()`, allows for decomposing
+a color into it's two basic parts: chromaticity and luminance. The result is a 3 coordinates list containing the 2D
+chromaticity coordinates followed by the luminance. By default, values are exported in the format u'v'Y, where u'v' is
+the chromaticity coordinates in the CIE 1976 system and Y is the luminance taken directly from XYZ.
+
+
+```py play
+Color('red').split_chromaticity()
+```
+
+If chromaticity coordinates are desired in a different format, any of the following can be manually specified.
+
+Key      | Output      | Description
+-------- | ----------- | -----------
+`xy-1931`| [x, y, Y]   | Chromaticity in the CIE 1931 xy system and luminance.
+`uv-1960`| [u, v, Y]   | Chromaticity in the CIE 1960 uv system and luminance.
+`uv-1976`| [u', v', Y] | Chromaticity in the CIE 1976 u'v' system and luminance.
+
+```py play
+Color('red').split_chromaticity('xy-1931')
+```
+
+All results are returned with chromaticities being relative to the current color's white point. This allows you to get
+the true chromaticities of that color space. If a pair of white point chromaticities are provided, the values will be
+chromatically adapted to match the given white point. `white` must be specified as an xy chromaticity pair. If you have
+chromaticity values in a non-xy pair, see [converting chromaticity coordinates](#converting-chromaticity-coordinates)
+to learn how to convert them to the expected format.
+
+```py play
+from coloraide import cat
+Color('red').split_chromaticity(white=cat.WHITES['2deg']['D50'])
+```
+
+/// tip
+If you ever need to get the white point from an already registered, supported color space, ColorAide makes these
+available via `white()`. The value is returned by default as the tristimulus values (XYZ coordinates), but it can also
+be returned as any of the supported chromaticity coordinate formats by specifying the desired output.
+
+```py play
+Color('red').white()
+Color('red').white('uv-1960')
+```
+///
+
+If all that is desired is the 2D chromaticity coordinates, you can also use the two, simple convenience methods: `xy()`
+and `uv()`. `xy()` will return chromaticity in the CIE 1931 xy system and `uv()` will return chromaticity within the
+CIE 1976 u'v' system (default) or the CIE 1960 uv system, uv output is controlled by explicitly passing the desired
+year of the uv system.
+
+```py play
+Color('red').xy()
+Color('red').uv()
+Color('red').uv('1960')
+```
+
+The `white` parameter is also accepted by `xy()` and `uv()`.
+
+/// tip | Luminance
+ColorAide also allows for grabbing luminance via the `luminance()` method. It should be noted that by default this
+function returns luminance relative to the D65 white point as it is common for people to use luminance normalized like
+this, but if you'd like to quickly get luminance and have it relative to the current color's white point, just set
+`white` to `#!py None` and ColorAide will calculate the value relative to the current color.
+
+```py play
+Color('red').luminance(white=None)
+```
+///
+
+/// new | New 2.4
+- `split_chromaticity()` is new in 2.4.
+- Chromaticity specifier in `white()` is new in 2.4.
+- `white` parameter of `luminance()` is new in 2.4.
+///
+
+## Create Color From Chromaticity Coordinates
+
+/// new | New 2.4
+///
+
+ColorAide also provides an easy way to create colors from chromaticity coordinates. `chromaticity()` is a generalized
+method that takes a color space to create the color in and a set of chromaticity coordinates. The coordinates should be
+supplied using the same white point as the targeted color space. Chromaticity coordinates can be passed as 2D
+coordinates without luminance or with luminance. When passed with luminance the color should be identical to the
+original.
+
+```py play
+uvY = Color('red').uv()
+Color.chromaticity('srgb', uvY)
+```
+
+If only 2D chromaticity points are given, Y will be assumed as 1. When luminance is maxed out like this, it may be
+desirable to normalize/scale the color in a linear RGB space to make the color displayable. This can be done by enabling
+`scale` which, by default, scales the color in linear sRGB. If a wider gamut is needed, you can change it via 
+`scale_space`. Using a non-linear RGB space is not recommended as non-linear spaces will cause the chromaticity
+coordinates to shift.
+
+```py play
+uv = Color('red').uv()
+Color.chromaticity('srgb', uv, scale=True)
+uv = Color('display-p3', [1, 0, 0]).uv()
+Color.chromaticity('display-p3', uv, scale=True, scale_space='display-p3-linear')
+```
+
+This generally preserves chromaticity, scaling luminance, but if a color is out of gamut, the chromaticity of the
+resultant color will be affected.
+
+/// tip
+There is no RGB color space that perfectly encompasses the entire visible gamut. No matter what scaling space is
+selected, colors outside the gamut of the scaling space will not exactly match the specified chromaticity coordinates.
+If exact values are needed, scaling should be avoided. If displaying the colors is desired, then sacrificing accuracy
+of the colors by scaling or some other gamut mapping method is necessary. Scaling/normalization is how we colorize all
+of our chromaticity diagrams in these documents.
+///
+
+It is important to be consistent with white point usage. If we wanted to create a color in sRGB with ProPhoto
+chromaticities, it is important that we create the color first under a color space that uses the same white point.
+ProPhoto uses D50 and sRGB uses D65. So if we had ProPhoto chromaticities, it makes sense to first create the color
+under ProPhoto and then convert to sRGB.
+
+```py play
+c1 = Color('prophoto-rgb', [1, 0, 0])
+c2 = Color.chromaticity('prophoto-rgb', c1.split_chromaticity()).convert('srgb')
+c1, c2.convert('prophoto-rgb')
+```
+
+With that said, there may be times when you have chromaticity coordinates that use a white point which no current
+supported color space supports. `chromaticity()` does provide a way of gamut mapping such coordinates by simply
+specifying the white point of the provided chromaticity coordinates. To illustrate, we'll take the same example, but
+this time create a color under sRGB directly with ProPhoto chromaticities, the difference is that we will pass
+ProPhoto's white point.
+
+```py play
+c1 = Color('prophoto-rgb', [1, 0, 0])
+c2 = Color.chromaticity('srgb', c1.split_chromaticity(), white=c1.white('xy-1931'))
+c1, c2.convert('prophoto-rgb')
+```
+
+## Converting Chromaticity Coordinates
+
+/// new | New 2.4
+///
+
+ColorAide normally expects you are working with chromaticity points that are compatible with at least one of the
+registered color spaces. In general, the API is set up with this expectation to make things easy for users. Normally,
+the user will not need to manually specify a white point, but it is possible that a user may be working with or
+exporting chromaticity coordinates to/from an unsupported, external space using an altogether different white point.
+We may need to specify that white point, but it may be in a format that ColorAide doesn't expect. Luckily, ColorAide
+provides a simple way to convert from between various chromaticity formats and convert to and from tristimulus (XYZ)
+values.
+
+```py play
+from coloraide import util
+Color('white').xy()
+Color.convert_chromaticity('uv-1976', 'xy-1931', Color('white').uv())
+Color.convert_chromaticity('uv-1960', 'xy-1931', Color('white').uv('1960'))
+Color.convert_chromaticity('xyz', 'xy-1931', Color('white').convert('xyz-d65').coords())
+```
+
+/// tip
+When converting from XYZ tristimulus values to chromaticity values, the color `#!color black` resolves to `[0, 0]` in
+xy, uv, or u'v'. This does not align with other achromatic values within the color space if displaying in 3D. This
+isn't technically incorrect as any chromaticity pair with zero luminance will be equal to `#!color black` in XYZ.
+ColorAide normally accounts for this and aligns the point using the targeted color's white point, but when manually
+converting using `convert_chromaticity()`, such context is unavailable. If you are converting external XYZ values to
+chromaticity coordinates and would like to align, `#!color black` on the achromatic axis, simply pass in the white point
+for context.
+
+```py play
+black = Color('black')
+black.xy()
+Color.convert_chromaticity('xyz', 'xy-1931', black.convert('xyz-d65').coords())
+Color.convert_chromaticity('xyz', 'xy-1931', black.convert('xyz-d65').coords(), white=black.white('xy-1931'))
+```
+///
diff --git a/docs/src/markdown/colors/rec709.md b/docs/src/markdown/colors/rec709.md
index 6ca1eb20c..2de54f6fd 100644
--- a/docs/src/markdown/colors/rec709.md
+++ b/docs/src/markdown/colors/rec709.md
@@ -1,5 +1,8 @@
 # Rec. 709
 
+/// new | New 2.4
+///
+
 /// failure | The Rec. 709 color space is not registered in `Color` by default
 ///
 
diff --git a/docs/src/markdown/colors/ucs.md b/docs/src/markdown/colors/ucs.md
index 86502f2aa..dff7525ae 100644
--- a/docs/src/markdown/colors/ucs.md
+++ b/docs/src/markdown/colors/ucs.md
@@ -1,5 +1,8 @@
 # CIE 1960 UCS
 
+/// new | New 2.4
+///
+
 /// failure | The CIE 1960 UCS color space is not registered in `Color` by default
 ///
 
diff --git a/docs/src/markdown/colors/xyy.md b/docs/src/markdown/colors/xyy.md
index 587309047..6b3ee517d 100644
--- a/docs/src/markdown/colors/xyy.md
+++ b/docs/src/markdown/colors/xyy.md
@@ -33,6 +33,12 @@ The sRGB gamut represented within the xyY color space.
 A derivative of the CIE 1931 XYZ space, the CIE xyY color space, is often used as a way to graphically present the
 chromaticity of colors.
 
+/// tip
+The color space, as implemented, is relative to the D65 white point, meaning it is created from XYZ D65. If colors are
+needed relative to different white points, the color space can be subclassed. If proper chromaticity coordinates are
+desired for a given color, you can checkout the [API for chromaticity coordinates](../chromaticity.md).
+///
+
 [Learn more](https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space).
 ///
 
diff --git a/docs/src/markdown/contrast.md b/docs/src/markdown/contrast.md
index dc175046a..013e34871 100644
--- a/docs/src/markdown/contrast.md
+++ b/docs/src/markdown/contrast.md
@@ -8,8 +8,7 @@ In the CIE XYZ and xyY color spaces, the Y parameter is linear to changes in the
 refers to the amount of reflected light where 1.0 is assumed to be a perfect reflector in relation to the reference
 white.
 
-The `luminance` method exposes access to this value to make it quick and easy to query the relative luminance, or Y
-parameter from XYZ D65 after converting the current color.
+The `luminance` method exposes access to this value to make it quick and easy to query the relative luminance.
 
 ```py play
 Color("black").luminance()
@@ -17,6 +16,10 @@ Color("white").luminance()
 Color("blue").luminance()
 ```
 
+It should be noted that this luminance is relative to the XYZ D65 color space by default as this is how it is defined
+in the WCAG 2.1. What this means is that luminance is equivalent to the Y value of XYZ D65. We follow this convention
+as many people expect it in this format.
+
 /// tip | Luminance and WCAG 2.1
 Luminance as described in the WCAG 2.1 spec is essentially the exact same as what the luminance method returns. The
 only difference is the lower precision by which they calculate the value:
@@ -32,6 +35,21 @@ Color('purple').convert('xyz-d65')['y']
 ```
 ///
 
+If you'd like to have luminance in relation to a given color's white point, you can set `white` to `#!py None`. If you'd
+like to get the luminance relative to some other white point, you can specify the white point as
+[xy chromaticity points](./chromaticity.md).
+
+```py play
+from coloraide import cat
+Color('prophoto-rgb', [1, 0, 0]).luminance()
+Color('prophoto-rgb', [1, 0, 0]).luminance(white=None)
+Color('prophoto-rgb', [1, 0, 0]).luminance(white=cat.WHITES['2deg']['E'])
+```
+
+/// new | New 2.4
+The `white` parameter is new in 2.4.
+///
+
 ## Contrast
 
 Chromatic contrast refers to the ability to see the difference of a colored object against a colored background. This
diff --git a/docs/src/markdown/gamut.md b/docs/src/markdown/gamut.md
index d9e09067c..b1a2d4ffd 100644
--- a/docs/src/markdown/gamut.md
+++ b/docs/src/markdown/gamut.md
@@ -346,3 +346,53 @@ very useful, but if the idea is to present an in gamut color that tries to prese
 original color as possible, other methods may be desired. There are no doubt better gamut methods available than what
 ColorAide offers currently, and more may be added in the future, but ColorAide can also be extended using 3rd party
 plugins as well.
+
+## Pointer's Gamut
+
+/// new | New 2.4
+///
+
+The Pointer’s gamut is (an approximation of) the gamut of real surface colors as can be seen by the human eye, based on
+the research by Michael R. Pointer (1980). What this means is that every color that can be reflected by the surface of
+an object of any material should be is inside the Pointer’s gamut. This does not include, however, those that do not
+occur naturally, such as neon lights, etc.
+
+![Pointer's Gamut](images/pointers-gamut.png)
+
+While in the above image, it may appear that most of sRGB is in the gamut, it is important to note that the image is
+showing the maximum range of the gamut. The actual boundary will be different at different luminance levels.
+
+![Pointer's Gamut lightness Levels](images/pointer-gamut-lightness.png)
+
+The gamuts previously discussed are bound by a color space's limits, but the Pointer's gamut applies to colors more
+generally and was created from observed data via research. Because it doesn't quite fit with the color space gamut API,
+ColorAide exposes two special functions to test if a color is in the Pointer's gamut and to fit a color to the gamut.
+
+To test if a color is within the gamut, simply call `in_pointer_gamut()`:
+
+```py play
+Color('red').in_pointer_gamut()
+Color('orange').in_pointer_gamut()
+```
+
+ColorAide also provides a way to fit a color to the Pointer's gamut. The original gamut's data is described in LCh using
+illuminant C. Using this color space, we can estimate the chroma limit for any color based on it's lightness and hue.
+We can then reduce the chroma, preserving the lightness and hue. The image below shows the out of Pointer's gamut color
+`#!color red` (indicated by the `x`) which is clamped to the Pointer's gamut by reducing the chroma (indicated by the
+dot).
+
+![Pointer's Gamut Fitted](images/pointer-gamut-fit.png)
+
+ColorAide provides the `fit_pointer_gamut()` method to perform this "fitting" of the color.
+
+```py play
+color = Color('red')
+color
+color.in_pointer_gamut()
+color.fit_pointer_gamut()
+color.in_pointer_gamut()
+```
+
+/// tip
+Much like `in_gamut()`, `in_pointer_gamut()` allows adjusting tolerance as well via the `tolerance` parameter.
+///
diff --git a/docs/src/markdown/images/1931-xy.png b/docs/src/markdown/images/1931-xy.png
new file mode 100644
index 000000000..4b48d9789
Binary files /dev/null and b/docs/src/markdown/images/1931-xy.png differ
diff --git a/docs/src/markdown/images/1960-uv.png b/docs/src/markdown/images/1960-uv.png
new file mode 100644
index 000000000..23e211bf7
Binary files /dev/null and b/docs/src/markdown/images/1960-uv.png differ
diff --git a/docs/src/markdown/images/1976-uv.png b/docs/src/markdown/images/1976-uv.png
new file mode 100644
index 000000000..64f0ec786
Binary files /dev/null and b/docs/src/markdown/images/1976-uv.png differ
diff --git a/docs/src/markdown/images/pointer-gamut-fit.png b/docs/src/markdown/images/pointer-gamut-fit.png
new file mode 100644
index 000000000..24b9e845f
Binary files /dev/null and b/docs/src/markdown/images/pointer-gamut-fit.png differ
diff --git a/docs/src/markdown/images/pointer-gamut-lightness.png b/docs/src/markdown/images/pointer-gamut-lightness.png
new file mode 100644
index 000000000..964ddc46b
Binary files /dev/null and b/docs/src/markdown/images/pointer-gamut-lightness.png differ
diff --git a/docs/src/markdown/images/pointers-gamut.png b/docs/src/markdown/images/pointers-gamut.png
new file mode 100644
index 000000000..9bb702e29
Binary files /dev/null and b/docs/src/markdown/images/pointers-gamut.png differ
diff --git a/docs/src/markdown/plugins/cct.md b/docs/src/markdown/plugins/cct.md
index 5095132a7..d9342a357 100644
--- a/docs/src/markdown/plugins/cct.md
+++ b/docs/src/markdown/plugins/cct.md
@@ -17,7 +17,16 @@ class CCT(Plugin, metaclass=ABCMeta):
         """Calculate a color's CCT."""
 
     @abstractmethod
-    def from_cct(self, color: type[Color], kelvin: float, duv: float = 0.0, **kwargs: Any) -> Color:
+    def from_cct(
+        self,
+        color: type[Color],
+        space: str,
+        kelvin: float,
+        duv: float,
+        scale: bool,
+        scale_space: str | None,
+        **kwargs: Any
+    ) -> Color:
         """Calculate a color that satisfies the CCT."""
 ```
 
@@ -26,7 +35,7 @@ Once registered, the plugin can then be used via the `cct()` or `blackbody()` me
 
 ```py
 Color('orange').cct(method=NAME, **kwargs)
-Color.blackbody(kelvin, duv, method=NAME, **kwargs)
+Color.blackbody(space, kelvin, duv, method=NAME, **kwargs)
 ```
 
 If you'd like the user to configure the plugin on registration, you can define an `__init__` method. To register the
diff --git a/docs/src/markdown/temperature.md b/docs/src/markdown/temperature.md
index 0f3a81597..3ba7cce7f 100644
--- a/docs/src/markdown/temperature.md
+++ b/docs/src/markdown/temperature.md
@@ -1,5 +1,8 @@
 # Correlated Color Temperature
 
+/// new | New 2.4
+///
+
 Correlated color temperature (CCT) is a measurement of the average hue of light as it appears to the eye. It is
 expressed as the temperature (in Kelvins) something would need to be heated to glow at approximately the same color.
 
@@ -39,19 +42,19 @@ External CMFs could be used as long as they are in the appropriate format and no
 When anything gets warm enough it will start to give off light, and the hotter it gets, the more energetic the light
 is. As an object increases in temperature, it will shift from the red end of the spectrum to the blue end.
 
-ColorAide provides the `blackbody()` method to generate colors along the black body curve. Simply pass in a temperature
-in Kelvin and ColorAide will return an approximate color along the black body locus.
+ColorAide provides the `blackbody()` method to generate colors along the black body curve. Simply give it a color space
+in which the color should be generated and a temperature in Kelvin and ColorAide will return an approximate color along
+the black body locus.
 
 ```py play
-Steps([Color.blackbody(t, out_space='srgb') for t in range(1000, 15000, 50)])
+Steps([Color.blackbody('srgb', t) for t in range(1000, 15000, 50)])
 ```
 
-ColorAide provides two methods: `blackbody()` allows you to generate a color on the black body curve by providing it
-a temperature and [∆~uv~](#duv) in Kelvin and `cct()` which allows you to get an associated temperature and [∆~uv~](#duv)
-from a given color. [∆~uv~](#duv) will be discussed later.
+ColorAide also provides a method `cct()` which allows you to get an associated temperature and [∆~uv~](#duv) from a
+given color. [∆~uv~](#duv) will be discussed later.
 
 ```py play
-color = Color.blackbody(2000)
+color = Color.blackbody('srgb', 2000)
 color
 color.cct()
 ```
@@ -82,7 +85,7 @@ We can calculate a color's associated temperature and its ∆~uv~ along the asso
 CCT and ∆~uv~ can also be used together to get a specific color that satisfies those requirements.
 
 ```py play
-Color.blackbody(*Color('yellow').cct())
+Color.blackbody('srgb-linear', *Color('yellow').cct())
 ```
 
 ## Limitations
@@ -94,10 +97,13 @@ out to even this limit.
 
 ## Out of Gamut Temperatures
 
-It should be noted that `blackbody()` normalizes the returned colors by default as the colors are often much too bright
-initially, all having a max luminance. This normalization is usually done under a linear RGB color space, (linear sRGB
-being the default). Sometimes, depending on the range of temperature, the color can fall outside the gamut under
-evaluation.
+It should be noted that `blackbody()` normalizes/scales the returned colors by default as the colors are often much too
+bright initially, all having a max luminance. This scaling is usually done under a linear RGB color space, Linear
+Rec2020 being the default as it encompasses the entire curve.
+
+Keep in mind that if the color is not in the display gamut it will need to be gamut mapped, and the gamut mapped value
+will not exhibit the same temperature. How far off it is will be depends on the disparity of the gamut sizes and how the
+color was gamut mapped.
 
 //// html | figure
 ![Out of gamut CCT](images/cct-gamut.png)
@@ -107,33 +113,30 @@ CCT of 1200K in relation to the sRGB gamut.
 /////
 ////
 
-Colors that are outside the gamut will only be normalized to be inside the gamut and may not convert back to exactly the
-same temperature.
+Colors that are outside the traditional RGB gamut (0 - 1) will be scaled to be within that range. If the color is beyond
+the gamut of the scaling color space, it will not convert back to the same temperature.
 
 ```py play
-c = Color.blackbody(1200)
+c = Color.blackbody('srgb', 1200)
 c
 c.cct()
 ```
 
-If a larger gamut is desired, to reduce out of gamut colors, one can be specified by changing `space`. Additionally,
-normalization can be turned off entirely by setting it to `None`. When specifying the space under evaluation, it is
-encouraged to use linear RGB spaces.
+Using a larger gamut, such as Linear Rec. 2020 that encompasses the entire black body curve, can allow for more accurate
+results throughout the entire range, but this may be impractical if you need to work in a smaller gamut.
+
+The desired RGB color space to scale within can be specified via `scale_space`. And if no scaling is desired, it can be
+turned off by setting `scale` to `#!py False`.
 
 ```py play
-c1 = Color.blackbody(1200, space='display-p3-linear')
+c1 = Color.blackbody('display-p3', 1200, scale_space='display-p3-linear')
 c1
 c1.cct()
-c2 = Color.blackbody(1200, space=None)
+c2 = Color.blackbody('display-p3', 1200, scale=False)
 c2
 c2.cct()
 ```
 
-/// note | Output Space
-`out_space` does not affect normalization, it simply converts the returned color to the color space specified by the
-user.
-///
-
 ## Algorithms
 
 There are quite a few approaches to calculating to and from CCT, each with their strengths and weaknesses. ColorAide
@@ -149,8 +152,8 @@ Ohno\ 2013      | `ohno-2013`      | Utilizes a combined approach of a triangula
 in `blackbody()` or `cct()`.
 
 ```py play
-Color.blackbody(1500, method='ohno-2013').cct(method='ohno-2013')
-Color.blackbody(1500, method='robertson-1968').cct(method='robertson-1968')
+Color.blackbody('srgb-linear', 2500, method='ohno-2013').cct(method='ohno-2013')
+Color.blackbody('srgb-linear', 2500, method='robertson-1968').cct(method='robertson-1968')
 ```
 
 ### Robertson 1968
@@ -176,7 +179,7 @@ to increasingly less accurate results. Even some results below 100000K may alrea
 ///
 
 ```py play
-color = Color.blackbody(5000, duv=0.02, method='robertson-1968')
+color = Color.blackbody('srgb-linear', 5000, duv=0.02, method='robertson-1968')
 color
 color.cct(method='robertson-1968')
 ```
@@ -210,7 +213,7 @@ Custom.register(
     overwrite=True
 )
 
-Steps([Color.blackbody(t, out_space='srgb', method='robertson-1968') for t in range(1000, 15000, 50)])
+Steps([Color.blackbody('srgb', t, method='robertson-1968') for t in range(1000, 15000, 50)])
 ```
 
 ### Ohno 2013
@@ -239,7 +242,7 @@ associated with the expansion technique, all while maintaining good accuracy. Th
 [Roberson](#robertson-1968) approach, but it is a more accurate approach.
 
 ```py play
-color = Color.blackbody(5000, duv=0.02)
+color = Color.blackbody('srgb-linear', 5000, duv=0.02)
 color
 color.cct()
 ```
@@ -268,8 +271,8 @@ spline. The values for the table will be explicitly calculated on the fly. Perfo
 to no increase in accuracy.
 
 ```py play
-Color.blackbody(5000, duv=0.02).cct()
-Color.blackbody(5000, duv=0.02).cct(exact=True)
+Color.blackbody('srgb-linear', 5000, duv=0.02).cct()
+Color.blackbody('srgb-linear', 5000, duv=0.02).cct(exact=True)
 ```
 
 Lastly, the Ohno 2013 method can be used with other CMFs if desired. To do this, the plugin must be instantiated with
@@ -299,5 +302,5 @@ Custom.register(
     overwrite=True
 )
 
-Steps([Custom.blackbody(t) for t in range(1000, 15000, 50)])
+Steps([Custom.blackbody('srgb-linear', t) for t in range(1000, 15000, 50)])
 ```
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index 503b4bcb3..f377b008f 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -43,12 +43,13 @@ nav:
       - Color Averaging: average.md
       - Compositing and Blending: compositing.md
       - Color Distance and Delta E: distance.md
-      - Gamut Mapping: gamut.md
-      - Chromatic Adaptation: cat.md
-      - Contrast: contrast.md
-      - Color Harmonies: harmonies.md
       - Filters: filters.md
+      - Color Harmonies: harmonies.md
+      - Contrast: contrast.md
+      - Gamut Mapping: gamut.md
+      - Chromaticity: chromaticity.md
       - Correlated Color Temperature: temperature.md
+      - Chromatic Adaptation: cat.md
       - String Output: strings.md
       - Advanced Topics: advanced.md
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 6c89e37ef..dc9eb1b63 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -43,12 +43,13 @@ nav:
       - Color Averaging: average.md
       - Compositing and Blending: compositing.md
       - Color Distance and Delta E: distance.md
-      - Gamut Mapping: gamut.md
-      - Chromatic Adaptation: cat.md
-      - Contrast: contrast.md
-      - Color Harmonies: harmonies.md
       - Filters: filters.md
+      - Color Harmonies: harmonies.md
+      - Contrast: contrast.md
+      - Gamut Mapping: gamut.md
+      - Chromaticity: chromaticity.md
       - Correlated Color Temperature: temperature.md
+      - Chromatic Adaptation: cat.md
       - String Output: strings.md
       - Advanced Topics: advanced.md
 
diff --git a/tools/cie_diagrams.py b/tools/cie_diagrams.py
index cd604058f..60ffcdbcc 100644
--- a/tools/cie_diagrams.py
+++ b/tools/cie_diagrams.py
@@ -19,6 +19,7 @@
 from coloraide import algebra as alg  # noqa: E402
 from coloraide.temperature import ohno_2013  # noqa: E402
 from coloraide import cmfs  # noqa: E402
+from coloraide import gamut  # noqa: E402
 
 ALL_WHITES = copy.deepcopy(WHITES)
 ALL_WHITES['2deg']['D60'] = ColorAll.CS_MAP['aces2065-1'].WHITE
@@ -70,17 +71,21 @@
 }
 
 ISOTHERMS = {
-    100000: (0.025, '∞'),
-    10000: (0.025, '10000K'),
-    6000: (0.025, '6000K'),
-    4000: (0.025, '4000K'),
-    3000: (-0.040, '3000K'),
-    2000: (-0.034, '2000K'),
-    1500: (-0.040, '1500K'),
-    1000: (-0.040, '1000K')
+    100000: (0.005, '∞'),
+    10000: (0.005, '10000K'),
+    6000: (0.005, '6000K'),
+    4000: (0.005, '4000K'),
+    3000: (-0.020, '3000K'),
+    2000: (-0.014, '2000K'),
+    1500: (-0.020, '1500K'),
+    1000: (-0.020, '1000K')
 }
 
 
+class Color(ColorAll):
+    """Custom class for Pointer conversion."""
+
+
 def get_spline(x, y, steps=100):
     """Get spline."""
 
@@ -227,11 +232,11 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
         elif observer != '2deg':
             raise ValueError("Unrecognized 'observer': {}".format(observer))
 
-        self.mode = mode
         if mode not in ('1931', '1960', '1976'):
             raise ValueError("Unrecognized 'mode': {}".format(mode))
 
-        if self.mode == "1931":
+        self.chromaticity = ('xy-' + mode) if mode == '1931' else ('uv-' + mode)
+        if mode == "1931":
             self.spectral_locus_lables = labels_1931
             self.axis_labels = ('CIE x', 'CIE y')
             if observer == '2deg':
@@ -240,7 +245,7 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
             else:
                 self.locus_labels = cie_xy_10_deg_offsets
                 self.title = "CIE 1931 Chromaticy Diagram - 10˚ Degree Standard Observer"
-        elif self.mode == "1976":
+        elif mode == "1976":
             self.spectral_locus_lables = labels_1960
             self.axis_labels = ("CIE u'", "CIE v'")
             if observer == '2deg':
@@ -281,7 +286,7 @@ def __init__(self, mode="1931", observer='2deg', theme="light", title=""):
 def cie_diagram(
     mode="1931", observer="2deg", colorize=True, opacity=1, rgb_spaces=None,
     white_points=None, theme='light', title='', show_labels=True, axis=True,
-    show_legend=True, black_body=False, isotherms=False, cct=None
+    show_legend=True, black_body=False, isotherms=False, cct=None, pointer=False
 ):
     """CIE diagram."""
 
@@ -305,7 +310,7 @@ def cie_diagram(
     class Color(ColorAll):
         ...
 
-    Color.register(ohno_2013.Ohno2013(opt.observer, opt.white), overwrite=True)
+    Color.register([ohno_2013.Ohno2013(opt.observer, opt.white)], overwrite=True)
 
     xs = []
     ys = []
@@ -314,11 +319,8 @@ class Color(ColorAll):
     # Get points for the spectral locus
     for k, v in opt.observer.items():
         # Get the XYZ values in the correct format
-        x, y = util.xyz_to_xyY(v, opt.white)[:2]
-        if opt.mode == "1976":
-            x, y = util.xy_to_uv([x, y])
-        elif opt.mode == "1960":
-            x, y = util.xy_to_uv_1960([x, y])
+        xy = util.xyz_to_xyY(v)[:-1]
+        x, y = Color.convert_chromaticity('xy-1931', opt.chromaticity, xy)[:-1]
         xs.append(x)
         ys.append(y)
 
@@ -331,24 +333,43 @@ class Color(ColorAll):
     # Draw the bottom purple line
     xs.append(xs[0])
     ys.append(ys[0])
+    spaces = []
+
+    # Pointer gamut
+    if pointer:
+        for p in pointer:
+            bounds, color = p.split(':')
+            sx = []
+            sy = []
+            if bounds == 'max':
+                pts = gamut.pointer.pointer_gamut_boundary()
+                label = 'pointer'
+            else:
+                l = min(90, max(15, float(bounds)))
+                pts = gamut.pointer.pointer_gamut_boundary(l)
+                label = 'pointer L*={}'.format(round(l, 2))
+            pts.append(pts[0])
+            for pt in pts:
+                x, y = Color.convert_chromaticity('xy-1931', opt.chromaticity, pt[:-1])[:-1]
+                sx.append(x)
+                sy.append(y)
+            spaces.append(
+                (
+                    sx,
+                    sy,
+                    color,
+                    label,
+                    mpltpath.Path(list(zip(sx, sy)))
+                )
+            )
 
     # Calculate RGB triangles if one is specified
-    spaces = []
     if rgb_spaces:
         temp = Color('srgb', [])
         for space, color in rgb_spaces:
-            if opt.mode == '1931':
-                red = temp.mutate(space, [1, 0, 0]).xy()
-                green = temp.mutate(space, [0, 1, 0]).xy()
-                blue = temp.mutate(space, [0, 0, 1]).xy()
-            elif opt.mode == '1976':
-                red = temp.mutate(space, [1, 0, 0]).uv()
-                green = temp.mutate(space, [0, 1, 0]).uv()
-                blue = temp.mutate(space, [0, 0, 1]).uv()
-            else:
-                red = temp.mutate(space, [1, 0, 0]).uv('1960')
-                green = temp.mutate(space, [0, 1, 0]).uv('1960')
-                blue = temp.mutate(space, [0, 0, 1]).uv('1960')
+            red = temp.mutate(space, [1, 0, 0]).split_chromaticity(opt.chromaticity)
+            green = temp.mutate(space, [0, 1, 0]).split_chromaticity(opt.chromaticity)
+            blue = temp.mutate(space, [0, 0, 1]).split_chromaticity(opt.chromaticity)
             sx = [red[0], green[0], blue[0], red[0]]
             sy = [red[1], green[1], blue[1], red[1]]
             spaces.append(
@@ -373,14 +394,7 @@ class Color(ColorAll):
             (x / RESOLUTION for x in range(0, RESOLUTION + 1)),
             (x / RESOLUTION for x in range(0, RESOLUTION + 1))
         ):
-            srgb = Color('srgb', [])
             if path.contains_point(r):
-                if opt.mode == "1931":
-                    xyz = util.xy_to_xyz(r)
-                elif opt.mode == "1976":
-                    xyz = util.xy_to_xyz(util.uv_to_xy(r))
-                else:
-                    xyz = util.xy_to_xyz(util.uv_1960_to_xy(r))
                 o = 0.01 if spaces else opacity
                 if spaces:
                     for s in spaces:
@@ -389,10 +403,14 @@ class Color(ColorAll):
                             break
                 px.append(r[0])
                 py.append(r[1])
-                srgb.update('xyz-d65', xyz, o)
-                m = max(srgb[:-1])
-                srgb.update('srgb', [(i / m if m != 0 else 0) for i in srgb[:-1]], srgb[-1])
-                c.append(srgb.to_string(hex=True, fit="clip"))
+                srgb = Color.chromaticity(
+                    'srgb',
+                    r,
+                    opt.chromaticity,
+                    scale=True,
+                    scale_space='rec2020-linear'
+                ).set('alpha', o)
+                c.append(srgb.convert('srgb').to_string(hex=True))
 
         plt.scatter(
             px, py,
@@ -445,17 +463,9 @@ class Color(ColorAll):
         for wp in white_points:
             w = ALL_WHITES[observer][wp]
             annot.append(wp)
-            if opt.mode == '1931':
-                wx.append(w[0])
-                wy.append(w[1])
-            elif opt.mode == '1976':
-                uv = util.xy_to_uv(w)
-                wx.append(uv[0])
-                wy.append(uv[1])
-            else:
-                uv = util.xy_to_uv_1960(w)
-                wx.append(uv[0])
-                wy.append(uv[1])
+            xy = Color.convert_chromaticity('xy-1931', opt.chromaticity, w)[:-1]
+            wx.append(xy[0])
+            wy.append(xy[1])
         plt.scatter(
             wx,
             wy,
@@ -480,8 +490,8 @@ class Color(ColorAll):
         annot = []
         for value in cct:
             temp, duv = [float(v) for v in value.split(':')]
-            c = Color.blackbody(temp, duv)
-            bu, bv = c.xy() if opt.mode == '1931' else c.uv(opt.mode)
+            c = Color.blackbody('xyz-d65', temp, duv, normalize=False)
+            bu, bv = c.split_chromaticity(opt.chromaticity, white=opt.white)[:-1]
             annot.append('({}, {})'.format(round(bu, 4), round(bv, 4)))
             bx.append(bu)
             by.append(bv)
@@ -506,23 +516,25 @@ class Color(ColorAll):
         vaxis = []
         for kelvin in range(1000, 100001, 250):
             t = kelvin
-            c = Color.blackbody(t, space=None)
-            bu, bv = c.xy() if opt.mode == '1931' else c.uv(opt.mode)
+            c = Color.blackbody('xyz-d65', t, normalize=False)
+            bu, bv = c.split_chromaticity(opt.chromaticity, white=opt.white)[:-1]
             uaxis.append(bu)
             vaxis.append(bv)
 
             if isotherms and kelvin in ISOTHERMS:
                 duvx = []
                 duvy = []
-                for duv in (-0.02, 0.02) if kelvin < 100000 else (-0.01, 0.01):
-                    c = Color.blackbody(kelvin, duv, space=None)
-                    bu, bv = c.xy() if opt.mode == '1931' else c.uv(opt.mode)
+                duv_range = (-0.03, 0.03) if kelvin < 100000 else (-0.01, 0.01)
+                for duv in duv_range:
+                    c = Color.blackbody('xyz-d65', kelvin, duv, normalize=False)
+                    bu, bv = c.split_chromaticity(opt.chromaticity, white=opt.white)[:-1]
                     duvx.append(bu)
                     duvy.append(bv)
 
                 offset, label = ISOTHERMS[kelvin]
-                c = Color.blackbody(kelvin, offset, space=None)
-                bu, bv = c.xy() if opt.mode == '1931' else c.uv(opt.mode)
+                offset = duv_range[0 if offset < 0 else 1] + offset
+                c = Color.blackbody('xyz-d65', kelvin, offset, normalize=False)
+                bu, bv = c.split_chromaticity(opt.chromaticity, white=opt.white)[:-1]
 
                 plt.annotate(
                     label,
@@ -551,7 +563,6 @@ class Color(ColorAll):
             markersize=0,
             antialiased=True
         )
-        # `plt.scatter(uaxis, vaxis, c=opt.default_color)`
 
     # Plot the RGB triangles
     for item in spaces:
@@ -570,8 +581,8 @@ class Color(ColorAll):
         )
 
     # We current only add labels when drawing RGB triangles
-    if rgb_spaces and show_legend:
-        ax.legend()
+    if (rgb_spaces or pointer) and show_legend:
+        ax.legend(bbox_to_anchor=(1.05, 1.0), loc='upper left')
 
 
 def main():
@@ -582,6 +593,11 @@ def main():
     parser.add_argument('--cmfs', '-c', default="2deg", help="CMFS to use, e.g., '2deg' (default) or '10deg'.")
     parser.add_argument('--white-point', '-w', action='append', help="A white point to plot.")
     parser.add_argument('--cct', '-C', action='append', help="A point specified by 'CCT:Duv'.")
+    parser.add_argument(
+        '--pointer', '-P', action='append',
+        help="Show Pointer gamut by specifying an L* value and a color for the boundary '30:color'. "
+             "'max' can be used show the maximum gamut instead of the gamut at a specific lightness 'max:color'."
+    )
     parser.add_argument('--rgb', '-r', action='append', help="An RGB space to show on diagram: 'space:color'.")
     parser.add_argument('--title', '-t', default='', help="Override title with your own.")
     parser.add_argument('--transparent', '-p', action="store_true", help="Export with transparent background.")
@@ -611,7 +627,8 @@ def main():
         title=args.title,
         black_body=args.black_body,
         isotherms=args.isotherms,
-        cct=args.cct
+        cct=args.cct,
+        pointer=args.pointer
     )
 
     if args.output:
diff --git a/tools/in_pointer_gamut_diagram.py b/tools/in_pointer_gamut_diagram.py
new file mode 100644
index 000000000..3efb3c953
--- /dev/null
+++ b/tools/in_pointer_gamut_diagram.py
@@ -0,0 +1,187 @@
+"""
+Generate random points in and around the Pointer gamut indicate whether they are inside or outside the gamut.
+
+If a color is given, plot it against the gamut instead of random points.
+"""
+import sys
+import os
+import math
+import random
+import argparse
+import matplotlib.pyplot as plt
+
+sys.path.insert(0, os.getcwd())
+
+from coloraide import gamut  # noqa: E402
+from coloraide import algebra as alg  # noqa: E402
+from coloraide.everything import ColorAll  # noqa: E402
+from coloraide.spaces.lch import LCh  # noqa: E402
+from coloraide.spaces.lab import Lab  # noqa: E402
+
+
+class LabPointer(Lab):
+    """Lab Pointer's Gamut."""
+
+    BASE = 'xyz-d65'
+    NAME = 'lab-pointer'
+    SERIALIZE = ('--lab-pointer',)
+    WHITE = gamut.pointer.WHITE_POINT_SC
+
+
+class LChPointer(LCh):
+    """LCh Pointer's Gamut."""
+
+    BASE = 'lab-pointer'
+    NAME = 'lch-pointer'
+    SERIALIZE = ('--lch-pointer',)
+    WHITE = gamut.pointer.WHITE_POINT_SC
+
+
+class Color(ColorAll):
+    """Custom class for Pointer conversion."""
+
+
+Color.register([LabPointer(), LChPointer()])
+
+
+def plot_pointer_gamut(target, space_gamut, fit, title, dark=False):
+    """
+    Plot the Pointer gamut and random points inside and outside the gamut.
+
+    If a color is given, plot it against the gamut instead of random points.
+    """
+
+    if not dark:
+        plt.style.use('seaborn-v0_8-darkgrid')
+        default_color = 'black'
+    else:
+        plt.style.use('dark_background')
+        default_color = 'white'
+
+    lightness = random.uniform(15, 90)
+    colors = []
+    if not target:
+        for _ in range(20):
+            while True:
+                color = Color.random('lch-pointer', limits=[(lightness, lightness), None, None])
+                if color.in_gamut(space_gamut, tolerance=0):
+                    colors.append(color)
+                    break
+    else:
+        original = Color(target)
+        colors.append(original.convert('lch-pointer', norm=False))
+        lightness = colors[0]['l']
+        if fit and not colors[0].in_pointer_gamut():
+            colors.append(colors[0].clone().fit_pointer_gamut())
+    color = colors[0]
+    l, c, h = color[:-1]
+    li, lf = gamut.pointer.closest_lightness(l)
+    chroma = [alg.lerp(row[li], row[li + 1], lf) for row in gamut.pointer.LCH_POINTER]
+    chroma.append(chroma[0])
+
+    figure = plt.figure()
+
+    ax = plt.axes(
+        xlabel='hue',
+        ylabel='chroma',
+        projection='polar'
+    )
+
+    ax.set_aspect('equal')
+    figure.add_axes(ax)
+
+    if not title:
+        plt.suptitle('Pointer Gamut: LCh SC at Lightness {}'.format(round(lightness, 2)))
+    if target:
+        if len(colors) > 1:
+            space = original.space()
+            ax.set_title(
+                'Color: {} -> {}'.format(colors[0].convert(space).to_string(), colors[1].convert(space).to_string()),
+                fontdict={'fontsize': 8},
+                pad=2
+            )
+        else:
+            ax.set_title('Color: {}'.format(colors[0].to_string()), fontdict={'fontsize': 8}, pad=2)
+
+    plt.plot(
+        [math.radians(hue) for hue in gamut.pointer.LCH_H] + [0.0],
+        chroma,
+        color=default_color,
+        marker="",
+        linewidth=1.5,
+        markersize=2,
+        antialiased=True
+    )
+
+    inside = []
+    outside = []
+
+    for color in colors:
+        c, h = color[1:-1]
+        if color.in_pointer_gamut():
+            inside.append([c, math.radians(h)])
+        else:
+            outside.append([c, math.radians(h)])
+
+    if inside:
+        y, x = zip(*inside)
+        plt.scatter(
+            x,
+            y,
+            marker="o",
+            color=default_color,
+            edgecolor=default_color,
+            s=16,
+            zorder=100
+        )
+
+    if outside:
+        y, x = zip(*outside)
+        plt.scatter(
+            x,
+            y,
+            marker="x",
+            color=default_color,
+            s=16,
+            zorder=100
+        )
+
+
+def main():
+    """Main."""
+
+    parser = argparse.ArgumentParser(
+        prog='pointer_gamut_diagram', description='Plot random points in relation to Pointer gamut.'
+    )
+    parser.add_argument(
+        '--gamut', '-g', default="display-p3", help='Gamut to evaluate the color in (default is display-p3).'
+    )
+    parser.add_argument('--fit', '-f', action="store_true", help="Fit color to pointer gamut.")
+    parser.add_argument(
+        '--color', '-c',
+        help="Color to test against the Pointer gamut, single colors will show the original and fitted color."
+    )
+    parser.add_argument('--title', '-t', default='', help="Provide a title for the diagram.")
+    parser.add_argument('--dark', action="store_true", help="Use dark theme.")
+    parser.add_argument('--dpi', default=200, type=int, help="DPI of image.")
+    parser.add_argument('--output', '-o', default='', help='Output file.')
+
+    args = parser.parse_args()
+
+    plot_pointer_gamut(
+        args.color,
+        args.gamut,
+        args.fit,
+        args.title,
+        dark=args.dark
+    )
+
+    if args.output:
+        plt.savefig(args.output, dpi=args.dpi)
+    else:
+        plt.gcf().set_dpi(args.dpi)
+        plt.show()
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/slice_diagram.py b/tools/slice_diagram.py
index 09ec9468d..98165155a 100644
--- a/tools/slice_diagram.py
+++ b/tools/slice_diagram.py
@@ -51,7 +51,8 @@ def plot_slice(
     title="",
     subtitle='',
     polar=False,
-    border=False
+    border=False,
+    pointer=False
 ):
     """Plot a slice."""
 
@@ -156,7 +157,11 @@ def plot_slice(
     for t1, t2 in zip(s1, s2):
         for r in Color.steps([t1, t2], steps=res, space=space, hue='specified'):
             # Only process colors within the specified gamut.
-            if r.in_gamut(gamut, tolerance=0) and (not is_lchish or not ignore_LCh_high_chroma_black(r)):
+            if (
+                r.in_gamut(gamut, tolerance=0) and
+                (not pointer or r.in_pointer_gamut(tolerance=0)) and
+                (not is_lchish or not ignore_LCh_high_chroma_black(r))
+            ):
                 c1 = r[index1]
                 c2 = r[index2]
 
@@ -295,6 +300,7 @@ def main():
     parser = argparse.ArgumentParser(prog='slice_diagrams', description='Plot a slice of a color space.')
     parser.add_argument('--space', '-s', help='Desired space.')
     parser.add_argument('--gamut', '-g', default="srgb", help='Gamut to evaluate the color in (default is sRGB).')
+    parser.add_argument('--pointer', '-P', action='store_true', help="Restrict to Pointer gamut")
     parser.add_argument(
         '--constant', '-c', help="The channel(s) to hold constant and the value to use 'name:value;name2:value2'."
     )
@@ -324,7 +330,8 @@ def main():
         subtitle=args.sub_title,
         dark=args.dark,
         polar=args.polar,
-        border=not args.no_border
+        border=not args.no_border,
+        pointer=args.pointer
     )
 
     if args.output:


Test patch:
diff --git a/tests/test_algebra.py b/tests/test_algebra.py
index bbf33314e..3f0b65a55 100644
--- a/tests/test_algebra.py
+++ b/tests/test_algebra.py
@@ -892,6 +892,11 @@ def test_is_nan(self):
         self.assertFalse(alg.is_nan(3))
         self.assertFalse(alg.is_nan(0))
 
+    def test_round_inf(self):
+        """Test rounding of infinity."""
+
+        self.assertEqual(alg.round_to(alg.INF, 2), alg.INF)
+
     def test_round(self):
         """Test rounding."""
 
diff --git a/tests/test_api.py b/tests/test_api.py
index d673b1f49..b521c7d23 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -254,13 +254,6 @@ def test_convert_fit_clip(self):
         c4 = c1.convert("hsl", fit=True)
         self.assertColorNotEqual(c2, c4)
 
-    def test_luminance(self):
-        """Test luminance."""
-
-        c1 = Color('orange')
-        c2 = c1.convert('xyz-d65')
-        self.assertEqual(c1.luminance(), c2.get('y'))
-
     def test_property(self):
         """Test set."""
 
diff --git a/tests/test_cct.py b/tests/test_cct.py
index 1df6129cb..f86888eb1 100644
--- a/tests/test_cct.py
+++ b/tests/test_cct.py
@@ -20,7 +20,7 @@ def test_cct(self, cct):
         """
 
         for duv in (-0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03):
-            cct2, duv2 = Color.blackbody(cct, duv, space=None).cct()
+            cct2, duv2 = Color.blackbody('xyz-d65', cct, duv, scale=False).cct()
             assert math.isclose(cct2, cct, rel_tol=(0.00001 if cct < 84000 else 0.0001), abs_tol=0.000001)
             assert math.isclose(duv2, duv, rel_tol=0.000001, abs_tol=0.000001)
 
@@ -33,7 +33,9 @@ def test_cct(self, cct):
         """Test CCT methods."""
 
         for duv in (-0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03):
-            cct2, duv2 = Color.blackbody(cct, duv, space=None, method='robertson-1968').cct(method='robertson-1968')
+            cct2, duv2 = Color.blackbody(
+                'xyz-d65', cct, duv, scale=False, method='robertson-1968'
+            ).cct(method='robertson-1968')
             assert math.isclose(cct2, cct, rel_tol=0.00001, abs_tol=0.00001)
             assert math.isclose(duv2, duv, rel_tol=0.00001, abs_tol=0.00001)
 
@@ -57,7 +59,7 @@ def test_cct(self, color):
         """Test CCT methods."""
 
         cct, duv = Color(color).cct()
-        c = Color.blackbody(cct, duv, space=None)
+        c = Color.blackbody('xyz-d65', cct, duv, scale=False)
         cct2, duv2 = c.cct()
         assert math.isclose(cct, cct2, rel_tol=0.00001, abs_tol=0.00001)
         assert math.isclose(duv, duv2, rel_tol=0.00001, abs_tol=0.00001)
@@ -82,7 +84,7 @@ def test_cct(self, color):
         """Test CCT methods."""
 
         cct, duv = Color(color).cct(method='robertson-1968')
-        c = Color.blackbody(cct, duv, space=None, method='robertson-1968')
+        c = Color.blackbody('xyz-d65', cct, duv, scale=False, method='robertson-1968')
         cct2, duv2 = c.cct(method='robertson-1968')
         assert math.isclose(cct, cct2, rel_tol=0.00001, abs_tol=0.00001)
         assert math.isclose(duv, duv2, rel_tol=0.00001, abs_tol=0.00001)
@@ -95,7 +97,7 @@ def test_bad_algorithm(self):
         """Test bad algorithm inputs."""
 
         with self.assertRaises(ValueError):
-            Color.blackbody(2000, method='bad')
+            Color.blackbody('srgb', 2000, method='bad')
 
         with self.assertRaises(ValueError):
             Color('blue').cct(method='bad')
@@ -104,7 +106,7 @@ def test_outside_gamut(self):
         """Test temperature outside of gamut."""
 
         self.assertColorEqual(
-            Color.blackbody(1500),
+            Color.blackbody('srgb-linear', 1500, scale_space='srgb-linear'),
             Color('color(srgb-linear 1 0.14969 0)')
         )
 
@@ -112,7 +114,7 @@ def test_output_space(self):
         """Test output space."""
 
         self.assertColorEqual(
-            Color.blackbody(2000, out_space='display-p3'),
+            Color.blackbody('display-p3', 2000, scale_space='srgb-linear'),
             Color('color(display-p3 0.93958 0.56696 0.22947)')
         )
 
@@ -120,15 +122,15 @@ def test_normalization_space(self):
         """Test normalization space."""
 
         self.assertColorEqual(
-            Color.blackbody(2000, space='display-p3'),
-            Color('color(display-p3 1 0.6107 0.25811)')
+            Color.blackbody('display-p3', 2000, scale_space='display-p3-linear'),
+            Color('color(display-p3 1 0.60474 0.24675)')
         )
 
     def test_no_normalization_space(self):
         """Test normalization space."""
 
         self.assertColorEqual(
-            Color.blackbody(2000, space=None),
+            Color.blackbody('xyz-d65', 2000, scale=False),
             Color('color(xyz-d65 1.2743 1 0.14523)')
         )
 
@@ -145,7 +147,7 @@ class Custom(Color):
             overwrite=True
         )
 
-        srgbl = Custom.blackbody(5000)
+        srgbl = Custom.blackbody('srgb-linear', 5000, scale_space='srgb-linear')
         self.assertColorEqual(srgbl, Color('color(srgb-linear 1 0.77908 0.61805)'))
         cct, duv = srgbl.cct()
         assert math.isclose(cct, 5000.005435878293, rel_tol=1e-11, abs_tol=1e-11)
@@ -176,7 +178,7 @@ class Custom(Color):
             overwrite=True
         )
 
-        self.assertEqual(Custom.blackbody(1000, space=None).cct(), [1000.0, 0.0])
+        self.assertEqual(Custom.blackbody('xyz-d65', 1000, scale=False).cct(), [1000.0, 0.0])
 
     def test_robertson_sigfig(self):
         """Test that significant figure option."""
diff --git a/tests/test_chromaticity.py b/tests/test_chromaticity.py
index 7ec4656b7..bdd4286c4 100644
--- a/tests/test_chromaticity.py
+++ b/tests/test_chromaticity.py
@@ -94,6 +94,66 @@ def test_xy_1960(self, color, uv):
 class TestChromaticitySpecificCases(util.ColorAsserts, unittest.TestCase):
     """Test distance specific cases."""
 
+    def test_xy_luminance(self):
+        """Test xy with luminance."""
+
+        xyy = Color('red').split_chromaticity('xy-1931')
+        for v1, v2 in zip(xyy, [0.64, 0.33, 0.21264]):
+            self.assertCompare(v1, v2)
+
+    def test_uv_1960_luminance(self):
+        """Test uv 1960 with luminance."""
+
+        uvy = Color('red').split_chromaticity('uv-1960')
+        for v1, v2 in zip(uvy, [0.4507, 0.34859, 0.21264]):
+            self.assertCompare(v1, v2)
+
+    def test_uv_1976_luminance(self):
+        """Test uv 1976 with luminance."""
+
+        uvy = Color('red').split_chromaticity('uv-1976')
+        for v1, v2 in zip(uvy, [0.4507, 0.52289, 0.21264]):
+            self.assertCompare(v1, v2)
+
+    def test_new_xy_color(self):
+        """Test new xy color."""
+
+        c1 = Color('red')
+        xyy = c1.split_chromaticity('xy-1931')
+        c2 = Color.chromaticity('srgb', xyy, 'xy-1931')
+        self.assertColorEqual(c1, c2)
+
+    def test_new_uv_1960_color(self):
+        """Test new uv 1960 color."""
+
+        c1 = Color('red')
+        uvy = c1.split_chromaticity('uv-1960')
+        c2 = Color.chromaticity('srgb', uvy, 'uv-1960')
+        self.assertColorEqual(c1, c2)
+
+    def test_new_uv_1976_color(self):
+        """Test new uv 1976 color."""
+
+        c1 = Color('red')
+        uvy = c1.split_chromaticity('uv-1976')
+        c2 = Color.chromaticity('srgb', uvy, 'uv-1976')
+        self.assertColorEqual(c1, c2)
+
+    def test_chromaticity_bad_value(self):
+        """Test bad chromaticity input."""
+
+        with self.assertRaises(ValueError):
+            Color.chromaticity('srgb', [0, 0], 'bad')
+
+    def test_color_from_2D_chromaticity(self):
+        """Test new color from 2D chromaticity."""
+
+        c1 = Color('red')
+        xy = c1.xy()
+        c2 = Color.chromaticity('srgb', xy, 'xy-1931', scale=True, scale_space='srgb-linear')
+        self.assertEqual(c2.to_string(fit=False), 'rgb(255 0 0)')
+        [self.assertCompare(v1, v2) for v1, v2 in zip(c2.xy(), xy)]
+
     def test_uv_1960_to_xy(self):
         """Test `uv` 1960 to `xy`."""
 
@@ -138,3 +198,43 @@ def test_bad_uv_value(self):
 
         with self.assertRaises(ValueError):
             Color('red').uv('bad')
+
+    def test_chromaticity_no_xyz_value(self):
+        """Test chromaticity does not accept XYZ values."""
+
+        with self.assertRaises(ValueError):
+            Color('red').chromaticity('srgb', [0.33, 0.66], 'xyz')
+
+    def test_split_chromaticity_no_xyz_value(self):
+        """Test that split chromaticity does not convert to XYZ."""
+
+        with self.assertRaises(ValueError):
+            Color('red').split_chromaticity('xyz')
+
+    def test_convert_xyz_bad_coordinates(self):
+        """Test chromaticity conversion with XYZ values."""
+
+        with self.assertRaises(ValueError):
+            Color.convert_chromaticity('xyz', 'xy-1931', [0, 0])
+
+        with self.assertRaises(ValueError):
+            Color.convert_chromaticity('xyz', 'xy-1931', [0, 0, 0, 0])
+
+    def test_convert_chromaticity_bad_coordinates(self):
+        """Test chromaticity conversion with XYZ values."""
+
+        with self.assertRaises(ValueError):
+            Color.convert_chromaticity('xy-1931', 'xyz', [0])
+
+        with self.assertRaises(ValueError):
+            Color.convert_chromaticity('xy-1931', 'xyz', [0, 0, 0, 0])
+
+    def test_convert_chromaticity_no_convert(self):
+        """Test case when no conversion is needed."""
+
+        self.assertEqual(Color.convert_chromaticity('xy-1931', 'xy-1931', [0.33, 0.36, 1.0]), [0.33, 0.36, 1.0])
+
+    def test_convert_chromaticity_no_convert_no_luminance(self):
+        """Test case when no conversion is needed and no luminance is provided."""
+
+        self.assertEqual(Color.convert_chromaticity('xy-1931', 'xy-1931', [0.33, 0.36]), [0.33, 0.36, 1.0])
diff --git a/tests/test_contrast.py b/tests/test_contrast.py
index 485b4454e..d3a72901a 100644
--- a/tests/test_contrast.py
+++ b/tests/test_contrast.py
@@ -4,6 +4,24 @@
 from . import util
 
 
+class TestLuminance(util.ColorAsserts, unittest.TestCase):
+    """Test luminance cases."""
+
+    def test_luminance(self):
+        """Test luminance."""
+
+        c1 = Color('orange')
+        c2 = c1.convert('xyz-d65')
+        self.assertEqual(c1.luminance(), c2.get('y'))
+
+    def test_luminance_auto(self):
+        """Test luminance auto white point."""
+
+        c1 = Color('orange').convert('prophoto-rgb')
+        self.assertCompare(c1.luminance(), c1.convert('xyz-d65').get('y'))
+        self.assertCompare(c1.luminance(white=None), c1.convert('xyz-d50').get('y'))
+
+
 class TestContrast(util.ColorAsserts, unittest.TestCase):
     """Test miscellaneous API features."""
 
diff --git a/tests/test_gamut.py b/tests/test_gamut.py
index 0f85ac6d0..d56814f60 100644
--- a/tests/test_gamut.py
+++ b/tests/test_gamut.py
@@ -2,6 +2,7 @@
 import unittest
 import math
 from coloraide.everything import ColorAll as Color
+from coloraide import gamut
 from . import util
 
 
@@ -172,3 +173,85 @@ def test_blue(self):
             s2 = [c * 255 for c in Color(answer)[:-1]]
             for c1, c2 in zip(s1, s2):
                 self.assertTrue(math.isclose(c1, c2, abs_tol=1.0))
+
+
+class TestPointerGamut(util.ColorAsserts, unittest.TestCase):
+    """Test Pointer's gamut."""
+
+    def test_in_pointer_gamut_too_light(self):
+        """Test gamut check when too light."""
+
+        self.assertFalse(Color('white').in_pointer_gamut())
+
+    def test_in_pointer_gamut_too_dark(self):
+        """Test gamut check when too dark."""
+
+        self.assertFalse(Color('black').in_pointer_gamut())
+
+    def test_in_pointer_gamut(self):
+        """Test gamut check in gamut."""
+
+        self.assertTrue(Color('orange').in_pointer_gamut())
+
+    def test_out_of_pointer_gamut(self):
+        """Test gamut check out of gamut."""
+
+        self.assertFalse(Color('red').in_pointer_gamut())
+
+    def test_hue_wrap(self):
+        """Test when hue interpolation wraps."""
+
+        self.assertFalse(Color('deeppink').in_pointer_gamut())
+
+    def test_fit_pointer_gamut_too_light(self):
+        """Test fitting gamut when too light."""
+
+        self.assertColorEqual(Color('white').fit_pointer_gamut(), Color('rgb(226.33 226.33 226.33)'))
+
+    def test_fit_pointer_gamut_too_dark(self):
+        """Test fitting gamut when too dark."""
+
+        self.assertColorEqual(Color('black').fit_pointer_gamut(), Color('rgb(37.667 37.667 37.667)'))
+
+    def test_fit_pointer_gamut(self):
+        """Test gamut check out of gamut."""
+
+        self.assertColorEqual(Color('red').fit_pointer_gamut(), Color('rgb(244 46.539 23.139)'))
+
+    def test_pointer_boundary_too_light(self):
+        """Test when pointer boundary is request is too light."""
+
+        with self.assertRaises(ValueError):
+            gamut.pointer.pointer_gamut_boundary(0)
+
+    def test_pointer_boundary_too_dark(self):
+        """Test when pointer boundary is request is too dark."""
+
+        with self.assertRaises(ValueError):
+            gamut.pointer.pointer_gamut_boundary(100)
+
+    def test_pointer_boundary_mid(self):
+        """Test when pointer boundary request."""
+
+        boundary = gamut.pointer.pointer_gamut_boundary(50)
+        # Test a sample of the values
+        test = [boundary[0], boundary[5], boundary[8]]
+        expected = [[0.47971142940104206, 0.2384179501289976, 18.418651851244416],
+                    [0.634800855490809, 0.35129621215430396, 18.418651851244416],
+                    [0.49892034937725527, 0.45347791373057683, 18.418651851244416]]
+
+        for coords1, coords2 in zip(test, expected):
+            [self.assertCompare(a, b) for a, b in zip(coords1, coords2)]
+
+    def test_pointer_boundary_max(self):
+        """Test pointer boundary max request."""
+
+        boundary = gamut.pointer.pointer_gamut_boundary()
+        # Test a sample of the values
+        test = [boundary[0], boundary[5], boundary[8]]
+        expected = [[0.5076988072583095, 0.2255929648510513, 11.250973799663784],
+                    [0.634800855490809, 0.35129621215430396, 18.418651851244416],
+                    [0.5260711281340981, 0.462168144862833, 48.2781043708229]]
+
+        for coords1, coords2 in zip(test, expected):
+            [self.assertCompare(a, b) for a, b in zip(coords1, coords2)]
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 6cd9d4530..8b89e3714 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -281,15 +281,15 @@ class Custom(Color):
             pass
 
         Custom.deregister('cct:robertson-1968')
-        color = Color.blackbody(5000, method='robertson-1968')
+        color = Color.blackbody('srgb-linear', 5000, method='robertson-1968')
 
         with self.assertRaises(ValueError):
-            Custom.blackbody(5000, method='robertson-1968')
+            Custom.blackbody('srgb-linear', 5000, method='robertson-1968')
 
         # Now it is registered again
         Custom.register(Robertson1968())
         self.assertColorEqual(
-            Custom.blackbody(5000, method='robertson-1968'),
+            Custom.blackbody('srgb-linear', 5000, method='robertson-1968'),
             color
         )