Repository: pygfx/pygfx
Pull request #155 (2021-10-05 13:50:02)

Adding a color util
Checks one off in #33.

This is a POC / proposal to add a `Color` class to pygfx.

This makes it possible for users to provide colors in various ways. And when getting a color prop, it can be formatted and processed in various ways too. The user never needs to instantiate it directly (although she could).

We support every way to provide a color in CSS, except the "hsl(..)" format. I left that for another day. We also support color tuples as we've used them so far. ThreeJS promotes the use of using an integer to define a color, e.g. 0xff0000. I started out with supporting this, but eventually removed it, because that integer does not look like a color when shown in decimal notation, so it easily leads to confusion.

In the top of the module I left some ideas for further work, based on ThreeJS and Qt.

Todo:
* [x] Do we want this?
* [x] Implementation.
* [x] Tests.
* [x] ~Tweak and test the hex-int format.~ drop int format
* [x] Tweak the repr.
* [x] Check how CSS, ThreeJS, and Qt handle color.
* [x] Implement where we use colors.


almarklein: This is ready for review. The `Color` class is done. I'll now apply it whereever a property/method accepts a property.
Korijn: LGTM.

Awesome stuff!

Patch:
diff --git a/examples/cube.py b/examples/cube.py
index d1b4612..0457f6d 100644
--- a/examples/cube.py
+++ b/examples/cube.py
@@ -12,7 +12,7 @@ scene = gfx.Scene()
 
 cube = gfx.Mesh(
     gfx.box_geometry(200, 200, 200),
-    gfx.MeshPhongMaterial(color=(0.2, 0.4, 0.6, 1.0)),
+    gfx.MeshPhongMaterial(color="#336699"),
 )
 scene.add(cube)
 
diff --git a/examples/line_basic.py b/examples/line_basic.py
index 675e6c6..60aca41 100644
--- a/examples/line_basic.py
+++ b/examples/line_basic.py
@@ -28,7 +28,7 @@ for i in range(len(positions)):
 
 line = gfx.Line(
     gfx.Geometry(positions=positions),
-    gfx.LineMaterial(thickness=12.0, color=(0.8, 0.7, 0.0, 1.0)),
+    gfx.LineMaterial(thickness=12.0, color=(0.8, 0.7, 0.0)),
 )
 scene.add(line)
 
diff --git a/pygfx/helpers/_axes.py b/pygfx/helpers/_axes.py
index 8e8fd33..33f6976 100644
--- a/pygfx/helpers/_axes.py
+++ b/pygfx/helpers/_axes.py
@@ -1,6 +1,7 @@
 import numpy as np
 
 from .. import Geometry, Line, LineThinSegmentMaterial
+from ..utils import Color
 
 
 class AxesHelper(Line):
@@ -38,6 +39,7 @@ class AxesHelper(Line):
         super().__init__(geometry, material)
 
     def set_colors(self, x, y, z):
+        x, y, z = Color(x), Color(y), Color(z)
         self._geometry.colors.data[0] = x
         self._geometry.colors.data[1] = x
         self._geometry.colors.data[2] = y
diff --git a/pygfx/helpers/_box.py b/pygfx/helpers/_box.py
index 73da241..dab8f97 100644
--- a/pygfx/helpers/_box.py
+++ b/pygfx/helpers/_box.py
@@ -47,7 +47,7 @@ class BoxHelper(Line):
         positions *= self._size
 
         geometry = Geometry(positions=positions)
-        material = LineThinSegmentMaterial(color=(1, 0, 0, 1))
+        material = LineThinSegmentMaterial(color=(1, 0, 0))
 
         super().__init__(geometry, material)
 
diff --git a/pygfx/helpers/_grid.py b/pygfx/helpers/_grid.py
index 941e7f5..46e5467 100644
--- a/pygfx/helpers/_grid.py
+++ b/pygfx/helpers/_grid.py
@@ -1,7 +1,7 @@
 import numpy as np
 
 from .. import Geometry, Line, LineThinSegmentMaterial
-
+from ..utils import Color
 
 DTYPE = "f4"
 
@@ -33,8 +33,8 @@ class GridHelper(Line):
 
         # color1 for the center lines, color2 for the rest
         colors = np.empty((2, n_lines, 2, 4), dtype=DTYPE)
-        colors[..., :] = color2
-        colors[:, n_lines // 2, :, :] = color1
+        colors[..., :] = Color(color2)
+        colors[:, n_lines // 2, :, :] = Color(color1)
 
         geometry = Geometry(
             positions=positions.reshape((-1, 3)), colors=colors.reshape((-1, 4))
diff --git a/pygfx/materials/_background.py b/pygfx/materials/_background.py
index 559f2db..600833e 100644
--- a/pygfx/materials/_background.py
+++ b/pygfx/materials/_background.py
@@ -1,4 +1,5 @@
 from ._base import Material
+from ..utils import Color
 
 # todo: in ThreeJS you can simply set a CubeTexture as the scene.background
 # we could do that, and the scene could use these objects automatically.
@@ -27,6 +28,7 @@ class BackgroundMaterial(Material):
         the botton and top. If four colors are given, it will be used for the
         four corners.
         """
+        colors = [Color(c) for c in colors]
         if len(colors) == 0:
             self.color_bottom_left = (0, 0, 0, 1)
             self.color_bottom_right = (0, 0, 0, 1)
@@ -53,41 +55,41 @@ class BackgroundMaterial(Material):
     @property
     def color_bottom_left(self):
         """The color in the bottom left corner."""
-        return self.uniform_buffer.data["color_bottom_left"]
+        return Color(self.uniform_buffer.data["color_bottom_left"])
 
     @color_bottom_left.setter
     def color_bottom_left(self, color):
-        self.uniform_buffer.data["color_bottom_left"] = color
+        self.uniform_buffer.data["color_bottom_left"] = Color(color)
         self.uniform_buffer.update_range(0, 1)
 
     @property
     def color_bottom_right(self):
         """The color in the bottom right corner."""
-        return self.uniform_buffer.data["color_bottom_right"]
+        return Color(self.uniform_buffer.data["color_bottom_right"])
 
     @color_bottom_right.setter
     def color_bottom_right(self, color):
-        self.uniform_buffer.data["color_bottom_right"] = color
+        self.uniform_buffer.data["color_bottom_right"] = Color(color)
         self.uniform_buffer.update_range(0, 1)
 
     @property
     def color_top_left(self):
         """The color in the top left corner."""
-        return self.uniform_buffer.data["color_top_left"]
+        return Color(self.uniform_buffer.data["color_top_left"])
 
     @color_top_left.setter
     def color_top_left(self, color):
-        self.uniform_buffer.data["color_top_left"] = color
+        self.uniform_buffer.data["color_top_left"] = Color(color)
         self.uniform_buffer.update_range(0, 1)
 
     @property
     def color_top_right(self):
         """The color in the top right corner."""
-        return self.uniform_buffer.data["color_top_right"]
+        return Color(self.uniform_buffer.data["color_top_right"])
 
     @color_top_right.setter
     def color_top_right(self, color):
-        self.uniform_buffer.data["color_top_right"] = color
+        self.uniform_buffer.data["color_top_right"] = Color(color)
         self.uniform_buffer.update_range(0, 1)
 
 
diff --git a/pygfx/materials/_line.py b/pygfx/materials/_line.py
index a891149..76073ce 100644
--- a/pygfx/materials/_line.py
+++ b/pygfx/materials/_line.py
@@ -1,5 +1,5 @@
 from ._base import Material
-from ..utils import unpack_bitfield
+from ..utils import unpack_bitfield, Color
 
 
 class LineMaterial(Material):
@@ -29,11 +29,11 @@ class LineMaterial(Material):
 
     @property
     def color(self):
-        return self.uniform_buffer.data["color"]
+        return Color(self.uniform_buffer.data["color"])
 
     @color.setter
     def color(self, color):
-        color = tuple(color)
+        color = Color(color)
         if (color[3] >= 1) != (self.uniform_buffer.data["color"][3] >= 1):
             self._bump_rev()  # rebuild pipeline if this becomes opaque/transparent
         self.uniform_buffer.data["color"] = color
diff --git a/pygfx/materials/_mesh.py b/pygfx/materials/_mesh.py
index a604196..4477760 100644
--- a/pygfx/materials/_mesh.py
+++ b/pygfx/materials/_mesh.py
@@ -1,6 +1,7 @@
 from ._base import Material
 from ..resources import TextureView
 from ..utils import unpack_bitfield
+from ..utils.color import Color
 
 
 def clim_from_format(texture):
@@ -86,11 +87,11 @@ class MeshBasicMaterial(Material):
         """The uniform color of the mesh, as an rgba tuple.
         This value is ignored if a texture map is used.
         """
-        return self.uniform_buffer.data["color"]
+        return Color(self.uniform_buffer.data["color"])
 
     @color.setter
     def color(self, color):
-        color = tuple(color)
+        color = Color(color)
         if (color[3] >= 1) != (self.uniform_buffer.data["color"][3] >= 1):
             self._bump_rev()  # rebuild pipeline if this becomes opaque/transparent
         self.uniform_buffer.data["color"] = color
diff --git a/pygfx/materials/_points.py b/pygfx/materials/_points.py
index 4eefc60..2ee6f47 100644
--- a/pygfx/materials/_points.py
+++ b/pygfx/materials/_points.py
@@ -1,5 +1,5 @@
 from ._base import Material
-from ..utils import unpack_bitfield
+from ..utils import unpack_bitfield, Color
 
 
 class PointsMaterial(Material):
@@ -39,11 +39,11 @@ class PointsMaterial(Material):
     @property
     def color(self):
         """The color of the points (if map is not set)."""
-        return self.uniform_buffer.data["color"]
+        return Color(self.uniform_buffer.data["color"])
 
     @color.setter
     def color(self, color):
-        color = tuple(color)
+        color = Color(color)
         if (color[3] >= 1) != (self.uniform_buffer.data["color"][3] >= 1):
             self._bump_rev()  # rebuild pipeline if this becomes opaque/transparent
         self.uniform_buffer.data["color"] = color
diff --git a/pygfx/renderers/wgpu/backgroundrender.py b/pygfx/renderers/wgpu/backgroundrender.py
index 08030b2..59b2348 100644
--- a/pygfx/renderers/wgpu/backgroundrender.py
+++ b/pygfx/renderers/wgpu/backgroundrender.py
@@ -139,16 +139,16 @@ class BackgroundShader(WorldObjectShader):
             // We can apply clipping planes, but maybe a background should not be clipped?
             // apply_clipping_planes(in.world_pos);
 
-            $$ if write_pick
-                // This is the opaque pass.
-                // A fragment of the background could be transparent, but it should still be
-                // written in the opaque pass in order for it to really be background.
-                // So we fool the blender into thinking this fragment is opaque, even if its not.
-                var out = get_fragment_output(varyings.position.z, vec4<f32>(final_color.rgb, 1.0));
-                out.color = final_color;
-                return out;
-            $$ else
-                discard;
+            $$ if not write_pick
+                if (true) { discard; }
             $$ endif
+
+            // This is the opaque pass.
+            // A fragment of the background could be transparent, but it should still be
+            // written in the opaque pass in order for it to really be background.
+            // So we fool the blender into thinking this fragment is opaque, even if its not.
+            var out = get_fragment_output(varyings.position.z, vec4<f32>(final_color.rgb, 1.0));
+            out.color = final_color;
+            return out;
         }
         """
diff --git a/pygfx/utils/__init__.py b/pygfx/utils/__init__.py
index a316012..c5cc4a6 100644
--- a/pygfx/utils/__init__.py
+++ b/pygfx/utils/__init__.py
@@ -2,6 +2,8 @@ import logging
 
 import numpy as np
 
+from .color import Color  # noqa: F401
+
 
 logger = logging.getLogger("pygfx")
 logger.setLevel(logging.WARNING)
diff --git a/pygfx/utils/color.py b/pygfx/utils/color.py
new file mode 100644
index 0000000..f4c55ea
--- /dev/null
+++ b/pygfx/utils/color.py
@@ -0,0 +1,423 @@
+"""Provides utilities to deal with color."""
+
+# Possible improvements:
+#
+# * Support for HSL colorspace:
+#   * Accept CSS compatible "hsl(...)" and "hsla(...)"
+#   * Color.hsl property
+#   * Color.from_hsl() classmethod
+# * Support for HSV color space?
+# * Support for HSLuv, a colorspace with uniform brightness https://www.hsluv.org/
+# * Color.__add__ another color or a scalar.
+# * Color.__mul__ another color or a scalar.
+# * Color.lerp(color, t) linear interpolate towards other color.
+# * Color.lerpHSL(color, t) same as lerp but interpolate in HSL space.
+# * Color.lighter(factor) and Color.darker(factort)
+
+# todo Color.__eq__
+# todo: Support "rgb(100%, 50%, 0%)"
+
+
+import ctypes
+
+
+F4 = ctypes.c_float * 4
+
+
+def _float_from_css_value(v, i):
+    v = v.strip()
+    if v.endswith("%"):
+        return float(v[:-1]) / 100
+    elif i < 3:
+        return float(v) / 255
+    else:
+        return float(v)
+
+
+class Color:
+    """An object representing a color.
+
+    Internally the color is stored using 4 32-bit floats (rgba).
+    It can be instantiated in a variety of ways. E.g. by providing
+    the color components as values between 0 and 1:
+
+    * `Color(r, g, b, a)` providing rgba values.
+    * `Color(r, g, b)` providing rgb, alpha is 1.
+    * `Color(gray, a)` grayscale intensity and alpha.
+    * `Color(gray)` grayscale intensity.
+
+    The above variations can also be supplied as a single tuple/list,
+    or anything that :
+
+    * `Color((r, g, b))`.
+
+    Named colors:
+
+    * `Color("red")` base color names.
+    * `Color("cornflowerblue")` CSS color names.
+    * `Color("m")` Matlab color chars.
+
+    Hex colors:
+
+    * `Color("#ff0000")` the commmon hex format.
+    * `Color("#ff0000ff")` the hex format that includes alpha.
+    * `Color("#ff0")` the short form hex format.
+    * `Color("#ff0f")` the short form hex format that includes alpha.
+
+    CSS color functions:
+
+    * `Color("rgb(255, 0, 0)")`.
+    * `Color("rgba(255, 0, 0, 1.0)")`.
+
+    """
+
+    # Internally, the color is a ctypes float array
+    __slots__ = ["_val"]
+
+    def __init__(self, *args):
+
+        if len(args) == 1:
+            color = args[0]
+            if isinstance(color, (int, float)):
+                self._set_from_tuple(args)
+            elif isinstance(color, str):
+                self._set_from_str(color)
+            else:
+                # Assume it's an iterable,
+                # may raise TypeError 'object is not iterable'
+                self._set_from_tuple(color)
+        else:
+            self._set_from_tuple(args)
+
+    def __repr__(self):
+        # A precision of 4 decimals, i.e. 10001 possible values for each color.
+        # We truncate zeros, but make sure the value does not end with a dot.
+        f = lambda v: f"{v:0.4f}".rstrip("0").ljust(3, "0")  # noqa: stfu
+        return f"Color({f(self.r)}, {f(self.g)}, {f(self.b)}, {f(self.a)})"
+
+    def __len__(self):
+        return 4
+
+    def __getitem__(self, index):
+        return self._val[index]
+
+    def __iter__(self):
+        return self.rgba.__iter__()
+
+    @property
+    def __array_interface__(self):
+        # Numpy can wrap our memory in an array without copying
+        readonly = True
+        ptr = ctypes.addressof(self._val)
+        x = dict(version=3, shape=(4,), typestr="<f4", data=(ptr, readonly))
+        return x
+
+    def _set_from_rgba(self, r, g, b, a):
+        self._val = F4(
+            max(0.0, min(1.0, float(r))),
+            max(0.0, min(1.0, float(g))),
+            max(0.0, min(1.0, float(b))),
+            max(0.0, min(1.0, float(a))),
+        )
+
+    def _set_from_tuple(self, color):
+        color = tuple(float(c) for c in color)
+        if len(color) == 4:
+            self._set_from_rgba(*color)
+        elif len(color) == 3:
+            self._set_from_rgba(*color, 1)
+        elif len(color) == 2:
+            self._set_from_rgba(color[0], color[0], color[0], color[1])
+        elif len(color) == 1:
+            self._set_from_rgba(color[0], color[0], color[0], 1)
+        else:
+            raise ValueError(f"Cannot parse color tuple with {len(color)} values")
+
+    def _set_from_str(self, color):
+        color = color.lower()
+        if color.startswith("#"):
+            # A hex number
+            if len(color) == 7:  # #rrggbb
+                self._set_from_rgba(
+                    int(color[1:3], 16) / 255,
+                    int(color[3:5], 16) / 255,
+                    int(color[5:7], 16) / 255,
+                    1,
+                )
+            elif len(color) == 4:  # #rgb
+                self._set_from_rgba(
+                    int(color[1], 16) / 15,
+                    int(color[2], 16) / 15,
+                    int(color[3], 16) / 15,
+                    1,
+                )
+            elif len(color) == 9:  # #rrggbbaa
+                self._set_from_rgba(
+                    int(color[1:3], 16) / 255,
+                    int(color[3:5], 16) / 255,
+                    int(color[5:7], 16) / 255,
+                    int(color[7:9], 16) / 255,
+                )
+            elif len(color) == 5:  # #rgba
+                self._set_from_rgba(
+                    int(color[1], 16) / 15,
+                    int(color[2], 16) / 15,
+                    int(color[3], 16) / 15,
+                    int(color[4], 16) / 15,
+                )
+            else:
+                raise ValueError(
+                    f"Expecting 4, 5, 7, or 9 chars in a hex number, got {len(color)}."
+                )
+        elif color.startswith(("rgb(", "rgba(")):
+            # A CSS color 'function'
+            parts = color.split("(")[1].split(")")[0].split(",")
+            parts = [_float_from_css_value(p, i) for i, p in enumerate(parts)]
+            if len(parts) == 3:
+                self._set_from_rgba(parts[0], parts[1], parts[2], 1)
+            elif len(parts) == 4:
+                self._set_from_rgba(parts[0], parts[1], parts[2], parts[3])
+            else:
+                raise ValueError(
+                    f"CSS color {color.split('(')[0]}(..) must have 3 or 4 elements, not {len(parts)} "
+                )
+        else:
+            # Maybe a named color
+            try:
+                color_int = NAMED_COLORS[color.lower()]
+            except KeyError:
+                raise ValueError(f"Unknown color: '{color}'") from None
+            else:
+                self._set_from_str(color_int)
+
+    @property
+    def rgba(self):
+        """The RGBA tuple (values between 0 and 1)."""
+        return self._val[0], self._val[1], self._val[2], self._val[3]
+
+    @property
+    def rgb(self):
+        """The RGB tuple (values between 0 and 1)."""
+        return self._val[0], self._val[1], self._val[2]
+
+    @property
+    def r(self):
+        """The red value."""
+        return self._val[0]
+
+    @property
+    def g(self):
+        """The green value."""
+        return self._val[1]
+
+    @property
+    def b(self):
+        """The blue value."""
+        return self._val[2]
+
+    @property
+    def a(self):
+        """The alpha (transparency) value."""
+        return self._val[3]
+
+    @property
+    def gray(self):
+        """Return the grayscale intensity."""
+        # Calculate with the weighted (a.k.a. luminosity) method, same as Matlab
+        r, g, b = self.rgb
+        return 0.2989 * r + 0.5870 * g + 0.1140 * b
+
+    @property
+    def hex(self):
+        """The CSS hex string, e.g. "#00ff00". The alpha channel is ignored."""
+        r = int(self.r * 255 + 0.5)
+        b = int(self.b * 255 + 0.5)
+        g = int(self.g * 255 + 0.5)
+        i = (r << 16) + (g << 8) + b
+        return "#" + hex(i)[2:].rjust(6, "0")
+
+    @property
+    def hexa(self):
+        """The hex string including alpha, e.g. "#00ff00ff"."""
+        r = int(self.r * 255 + 0.5)
+        b = int(self.b * 255 + 0.5)
+        g = int(self.g * 255 + 0.5)
+        a = int(self.a * 255 + 0.5)
+        i = (r << 24) + (g << 16) + (b << 8) + a
+        return "#" + hex(i)[2:].rjust(8, "0")
+
+    @property
+    def css(self):
+        """The CSS color string, e.g. "rgba(0,255,0,0.5)"."""
+        r, g, b, a = self.rgba
+        if a == 1:
+            return f"rgb({int(255*r+0.5)},{int(255*g+0.5)},{int(255*b+0.5)})"
+        else:
+            return f"rgba({int(255*r+0.5)},{int(255*g+0.5)},{int(255*b+0.5)},{a:0.3f})"
+
+
+NAMED_COLORS = {
+    # CSS Level 1
+    "black": "#000000",
+    "silver": "#C0C0C0",
+    "gray": "#808080",
+    "white": "#FFFFFF",
+    "maroon": "#800000",
+    "red": "#FF0000",
+    "purple": "#800080",
+    "fuchsia": "#FF00FF",
+    "green": "#008000",
+    "lime": "#00FF00",
+    "olive": "#808000",
+    "yellow": "#FFFF00",
+    "navy": "#000080",
+    "blue": "#0000FF",
+    "teal": "#008080",
+    "aqua": "#00FFFF",
+    # CSS Level 2
+    "orange": "#FFA500",
+    # CSS Color Module Level 3
+    "aliceblue": "#F0F8FF",
+    "antiquewhite": "#FAEBD7",
+    "aquamarine": "#7FFFD4",
+    "azure": "#F0FFFF",
+    "beige": "#F5F5DC",
+    "bisque": "#FFE4C4",
+    "blanchedalmond": "#FFEBCD",
+    "blueviolet": "#8A2BE2",
+    "brown": "#A52A2A",
+    "burlywood": "#DEB887",
+    "cadetblue": "#5F9EA0",
+    "chartreuse": "#7FFF00",
+    "chocolate": "#D2691E",
+    "coral": "#FF7F50",
+    "cornflowerblue": "#6495ED",
+    "cornsilk": "#FFF8DC",
+    "crimson": "#DC143C",
+    "cyan": "#00FFFF",
+    "aqua": "#00FFFF",
+    "darkblue": "#00008B",
+    "darkcyan": "#008B8B",
+    "darkgoldenrod": "#B8860B",
+    "darkgray": "#A9A9A9",
+    "darkgreen": "#006400",
+    "darkgrey": "#A9A9A9",
+    "darkkhaki": "#BDB76B",
+    "darkmagenta": "#8B008B",
+    "darkolivegreen": "#556B2F",
+    "darkorange": "#FF8C00",
+    "darkorchid": "#9932CC",
+    "darkred": "#8B0000",
+    "darksalmon": "#E9967A",
+    "darkseagreen": "#8FBC8F",
+    "darkslateblue": "#483D8B",
+    "darkslategray": "#2F4F4F",
+    "darkslategrey": "#2F4F4F",
+    "darkturquoise": "#00CED1",
+    "darkviolet": "#9400D3",
+    "deeppink": "#FF1493",
+    "deepskyblue": "#00BFFF",
+    "dimgray": "#696969",
+    "dimgrey": "#696969",
+    "dodgerblue": "#1E90FF",
+    "firebrick": "#B22222",
+    "floralwhite": "#FFFAF0",
+    "forestgreen": "#228B22",
+    "gainsboro": "#DCDCDC",
+    "ghostwhite": "#F8F8FF",
+    "gold": "#FFD700",
+    "goldenrod": "#DAA520",
+    "greenyellow": "#ADFF2F",
+    "grey": "#808080",
+    "honeydew": "#F0FFF0",
+    "hotpink": "#FF69B4",
+    "indianred": "#CD5C5C",
+    "indigo": "#4B0082",
+    "ivory": "#FFFFF0",
+    "khaki": "#F0E68C",
+    "lavender": "#E6E6FA",
+    "lavenderblush": "#FFF0F5",
+    "lawngreen": "#7CFC00",
+    "lemonchiffon": "#FFFACD",
+    "lightblue": "#ADD8E6",
+    "lightcoral": "#F08080",
+    "lightcyan": "#E0FFFF",
+    "lightgoldenrodyellow": "#FAFAD2",
+    "lightgray": "#D3D3D3",
+    "lightgreen": "#90EE90",
+    "lightgrey": "#D3D3D3",
+    "lightpink": "#FFB6C1",
+    "lightsalmon": "#FFA07A",
+    "lightseagreen": "#20B2AA",
+    "lightskyblue": "#87CEFA",
+    "lightslategray": "#778899",
+    "lightslategrey": "#778899",
+    "lightsteelblue": "#B0C4DE",
+    "lightyellow": "#FFFFE0",
+    "limegreen": "#32CD32",
+    "linen": "#FAF0E6",
+    "magenta": "#FF00FF",
+    "fuchsia": "#FF00FF",
+    "mediumaquamarine": "#66CDAA",
+    "mediumblue": "#0000CD",
+    "mediumorchid": "#BA55D3",
+    "mediumpurple": "#9370DB",
+    "mediumseagreen": "#3CB371",
+    "mediumslateblue": "#7B68EE",
+    "mediumspringgreen": "#00FA9A",
+    "mediumturquoise": "#48D1CC",
+    "mediumvioletred": "#C71585",
+    "midnightblue": "#191970",
+    "mintcream": "#F5FFFA",
+    "mistyrose": "#FFE4E1",
+    "moccasin": "#FFE4B5",
+    "navajowhite": "#FFDEAD",
+    "oldlace": "#FDF5E6",
+    "olivedrab": "#6B8E23",
+    "orangered": "#FF4500",
+    "orchid": "#DA70D6",
+    "palegoldenrod": "#EEE8AA",
+    "palegreen": "#98FB98",
+    "paleturquoise": "#AFEEEE",
+    "palevioletred": "#DB7093",
+    "papayawhip": "#FFEFD5",
+    "peachpuff": "#FFDAB9",
+    "peru": "#CD853F",
+    "pink": "#FFC0CB",
+    "plum": "#DDA0DD",
+    "powderblue": "#B0E0E6",
+    "rosybrown": "#BC8F8F",
+    "royalblue": "#4169E1",
+    "saddlebrown": "#8B4513",
+    "salmon": "#FA8072",
+    "sandybrown": "#F4A460",
+    "seagreen": "#2E8B57",
+    "seashell": "#FFF5EE",
+    "sienna": "#A0522D",
+    "skyblue": "#87CEEB",
+    "slateblue": "#6A5ACD",
+    "slategray": "#708090",
+    "slategrey": "#708090",
+    "snow": "#FFFAFA",
+    "springgreen": "#00FF7F",
+    "steelblue": "#4682B4",
+    "tan": "#D2B48C",
+    "thistle": "#D8BFD8",
+    "tomato": "#FF6347",
+    "turquoise": "#40E0D0",
+    "violet": "#EE82EE",
+    "wheat": "#F5DEB3",
+    "whitesmoke": "#F5F5F5",
+    "yellowgreen": "#9ACD32",
+    # CSS Color Module Level 4
+    "rebeccapurple": "#663399",
+    # Matlab / Matplotlib
+    "b": "#0000FF",
+    "g": "#00FF00",
+    "r": "#FF0000",
+    "c": "#00FFFF",
+    "m": "#FF00FF",
+    "y": "#FFFF00",
+    "k": "#000000",
+    "w": "#FFFFFF",
+}


Test patch:
diff --git a/tests/utils/test_color.py b/tests/utils/test_color.py
new file mode 100644
index 0000000..468c0aa
--- /dev/null
+++ b/tests/utils/test_color.py
@@ -0,0 +1,215 @@
+from pytest import raises
+import numpy as np
+
+from pygfx.utils.color import Color, NAMED_COLORS
+
+
+class TColor(Color):
+    def matches(self, r, g, b, a):
+        eps = 0.501 / 255
+        rgba = r, g, b, a
+        return all(abs(v1 - v2) < eps for v1, v2 in zip(self.rgba, rgba))
+
+
+def test_color_basics():
+
+    c = Color(0.1, 0.2, 0.3)
+    assert repr(c) == "Color(0.1, 0.2, 0.3, 1.0)"
+    c = Color(0.012345, 0.2, 0.3, 0.8)
+    assert repr(c) == "Color(0.0123, 0.2, 0.3, 0.8)"
+
+    d = Color(c)
+    assert list(c) == list(d)
+
+
+def test_color_tuples():
+
+    # Test setting with values
+    assert TColor(0).matches(0, 0, 0, 1)
+    assert TColor(1).matches(1, 1, 1, 1)
+    assert TColor(0, 0).matches(0, 0, 0, 0)
+    assert TColor(1, 1).matches(1, 1, 1, 1)
+    assert TColor(0.5, 0.8).matches(0.5, 0.5, 0.5, 0.8)
+    assert TColor(0.1, 0.2, 0.3).matches(0.1, 0.2, 0.3, 1)
+    assert TColor(0.1, 0.2, 0.3, 0.8).matches(0.1, 0.2, 0.3, 0.8)
+
+    # Need at least two args to provide a color tuple
+    with raises(ValueError):
+        Color()
+
+    # Now with real tuples
+    assert TColor((0, 0)).matches(0, 0, 0, 0)
+    assert TColor((1, 1)).matches(1, 1, 1, 1)
+    assert TColor((0.5, 0.8)).matches(0.5, 0.5, 0.5, 0.8)
+    assert TColor((0.1, 0.2, 0.3)).matches(0.1, 0.2, 0.3, 1)
+    assert TColor((0.1, 0.2, 0.3, 0.8)).matches(0.1, 0.2, 0.3, 0.8)
+
+    # Can also do a 1-element tuple then
+    assert TColor((0.6,)).matches(0.6, 0.6, 0.6, 1)
+
+
+def test_color_iterable():
+
+    # Accepts any kind of iterable, like tuples and lists
+    assert TColor((0.1, 0.2, 0.3)).matches(0.1, 0.2, 0.3, 1)
+    assert TColor([0.1, 0.2, 0.3]).matches(0.1, 0.2, 0.3, 1)
+
+    # Like arrays
+    a = np.array([0.1, 0.2, 0.3])
+    assert TColor(a).matches(0.1, 0.2, 0.3, 1)
+
+    # And generators
+    def get_color():
+        yield 0.1
+        yield 0.2
+        yield 0.3
+
+    assert TColor(get_color()).matches(0.1, 0.2, 0.3, 1)
+
+    # And like a Color object ;)
+    c = Color((0.1, 0.2, 0.3))
+    assert TColor(c).matches(0.1, 0.2, 0.3, 1)
+
+    # Because its iterable
+    assert len(c) == 4  # This is *always* 4
+    assert c.rgba == tuple(c)
+
+    # Not iterable
+    with raises(TypeError):
+        Color(str)
+    # Too short
+    with raises(ValueError):
+        Color([])
+    # Too long
+    with raises(ValueError):
+        Color([0.1, 0.2, 0.3, 0.4, 0.5])
+
+
+def test_color_attr():
+
+    c = Color(0.1, 0.2, 0.3, 0.8)
+
+    assert c.rgb == c.rgba[:3]
+
+    assert c.r == c.rgba[0]
+    assert c.g == c.rgba[1]
+    assert c.b == c.rgba[2]
+    assert c.a == c.rgba[3]
+
+    assert 0.18 < c.gray < 0.22
+    assert 0.3999 < Color(0.4, 0.4, 0.4, 0.8).gray < 0.400001
+
+
+def test_color_indexing():
+
+    c = Color(0.1, 0.2, 0.3, 0.8)
+
+    assert c.r == c[0]
+    assert c.g == c[1]
+    assert c.b == c[2]
+    assert c.a == c[3]
+
+
+def test_color_numpy():
+
+    # Map an array to the color data
+    c = Color(0.1, 0.2, 0.3, 0.8)
+    a = np.array(c, copy=False)
+    assert a.dtype == np.float32
+    assert (a == c).all()
+
+    # We cannot change the array
+    assert not a.flags.writeable
+
+    # But we can change the color, with a hack
+    c._val[0] = 9
+    assert a[0] == 9
+
+
+def test_color_hex():
+
+    # Hex -> tuple
+    assert TColor("#000000").matches(0, 0, 0, 1)
+    assert TColor("#ffffff").matches(1, 1, 1, 1)
+    assert TColor("#7f7f7f").matches(0.5, 0.5, 0.5, 1)
+    assert TColor("#7f7f7f10").matches(0.5, 0.5, 0.5, 16 / 255)
+
+    # Tuple -> hex
+    assert TColor(0, 1).hex == "#000000"
+    assert TColor(0, 0.5, 1).hex == "#0080ff"
+    assert TColor(1, 0.5, 0).hex == "#ff8000"
+    assert TColor(0.1, 0.2, 0.3).hex == "#1a334d"
+
+    # Tuple -> hexa
+    assert TColor(0, 1).hexa == "#000000ff"
+    assert TColor(0, 0.5, 1).hexa == "#0080ffff"
+    assert TColor(1, 0.5, 0).hexa == "#ff8000ff"
+    assert TColor(0.1, 0.2, 0.3).hexa == "#1a334dff"
+    assert TColor(0.1, 0.2, 0.3, 0.5).hexa == "#1a334d80"
+
+    # Roundtrip between hex and tuple to make sure the
+    # values are stable and won't "jump"
+    for v in [0.0, 0.1, 0.23, 1 / 7, 0.99, 1.0]:
+        c = Color(v, v, v, 1)
+        for i in range(10):
+            c = TColor(c.hex)
+            assert c.matches(v, v, v, 1)
+
+    # Variations
+    assert Color("#123").hexa == "#112233ff"
+    assert Color("#1234").hexa == "#11223344"
+    assert Color("#112233").hexa == "#112233ff"
+    assert Color("#11223344").hexa == "#11223344"
+
+    for x in ["#1", "#12", "#12345", "#1234567", "#123456789"]:
+        with raises(ValueError):
+            Color(x)
+
+
+def test_color_css():
+
+    assert Color("rgb(10, 20, 30)").hexa == "#0a141eff"
+    assert Color("rgba(10, 20, 30, 0.5)").hexa == "#0a141e80"
+    assert TColor("rgb(10%, 20%, 30%)").matches(0.1, 0.2, 0.3, 1)
+    assert TColor("rgba(10%, 20%, 30%, 0.5)").matches(0.1, 0.2, 0.3, 0.5)
+
+    assert Color("#0a141eff").css == "rgb(10,20,30)"
+    assert Color("#0a141e80").css == "rgba(10,20,30,0.502)"
+
+    with raises(ValueError):
+        Color("rgb(10, 20, 30, 40, 50)")
+    with raises(ValueError):
+        Color("rgb(10, 20)")
+
+
+def test_color_min_max():
+
+    assert Color(1.1, 1.2, 1.3).rgb == (1, 1, 1)
+    assert Color(-0.1, -0.2, -0.3).rgb == (0, 0, 0)
+
+    assert Color("rgb(260, 270, 280)").css == "rgb(255,255,255)"
+
+
+def test_color_named():
+
+    assert Color("red").hexa == "#ff0000ff"
+    assert Color("y").hexa == "#ffff00ff"
+
+    with raises(ValueError):
+        Color("notacolorname")
+
+    # Make sure that all named colors can be consumed
+    for key in NAMED_COLORS:
+        Color(key)
+
+
+if __name__ == "__main__":
+    test_color_basics()
+    test_color_tuples()
+    test_color_iterable()
+    test_color_attr()
+    test_color_indexing()
+    test_color_numpy()
+    test_color_hex()
+    test_color_css()
+    test_color_min_max()