From 4507d78fff514b10dffce751d099d462dff11e75 Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 19 Nov 2025 10:01:58 -0500 Subject: [PATCH] Added Color.categorical_set that generates a creates a list of visually distinct colors --- src/build123d/geometry.py | 71 ++++++- tests/test_direct_api/test_color.py | 314 ++++++++++++++++++++-------- 2 files changed, 296 insertions(+), 89 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index e17d049..ff8f264 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -34,6 +34,7 @@ from __future__ import annotations # other pylint warning to temp remove: # too-many-arguments, too-many-locals, too-many-public-methods, # too-many-statements, too-many-instance-attributes, too-many-branches +import colorsys import copy as copy_module import itertools import json @@ -1180,7 +1181,7 @@ class Color: `OCCT Color Names `_ - Hexadecimal string may be RGB or RGBA format with leading "#" + Hexadecimal string may be RGB or RGBA format with leading "#" Args: name (str): color, e.g. "blue" or "#0000ff"" @@ -1345,6 +1346,74 @@ class Color: """Color repr""" return f"Color{str(tuple(self))}" + @classmethod + def categorical_set( + cls, + color_count: int, + starting_hue: ColorLike | float = 0.0, + alpha: float | Iterable[float] = 1.0, + ) -> list[Color]: + """Generate a palette of evenly spaced colors. + + Creates a list of visually distinct colors suitable for representing + discrete categories (such as different parts, assemblies, or data + series). Colors are evenly spaced around the hue circle and share + consistent lightness and saturation levels, resulting in balanced + perceptual contrast across all hues. + + Produces palettes similar in appearance to the **Tableau 10** and **D3 + Category10** color sets—both widely recognized standards in data + visualization for their clarity and accessibility. These values have + been empirically chosen to maintain consistent perceived brightness + across hues while avoiding overly vivid or dark colors. + + Args: + color_count (int): Number of colors to generate. + starting_hue (ColorLike | float): Either a Color-like object or + a hue value in the range [0.0, 1.0] that defines the starting color. + alpha (float | Iterable[float]): Alpha value(s) for the colors. Can be a + single float or an iterable of length `color_count`. + + Returns: + list[Color]: List of generated colors. + + Raises: + ValueError: If starting_hue is out of range or alpha length mismatch. + """ + + # --- Determine starting hue --- + if isinstance(starting_hue, float): + if not (0.0 <= starting_hue <= 1.0): + raise ValueError("Starting hue must be within range 0.0–1.0") + elif isinstance(starting_hue, int): + if starting_hue < 0: + raise ValueError("Starting color integer must be non-negative") + rgb = tuple(Color(starting_hue))[:3] + starting_hue = colorsys.rgb_to_hls(*rgb)[0] + else: + raise TypeError( + "Starting hue must be a float in [0,1] or an integer color literal" + ) + + # --- Normalize alpha values --- + if isinstance(alpha, (float, int)): + alphas = [float(alpha)] * color_count + else: + alphas = list(alpha) + if len(alphas) != color_count: + raise ValueError("Number of alpha values must match color_count") + + # --- Generate color list --- + hues = np.linspace( + starting_hue, starting_hue + 1.0, color_count, endpoint=False + ) + colors = [ + cls(*colorsys.hls_to_rgb(h % 1.0, 0.55, 0.9), a) + for h, a in zip(hues, alphas) + ] + + return colors + @staticmethod def _rgb_from_int(triplet: int) -> tuple[float, float, float]: red, remainder = divmod(triplet, 256**2) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 650f5c9..9e50a8a 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -26,8 +26,9 @@ license: """ +import colorsys import copy - +import math import numpy as np import pytest @@ -36,129 +37,196 @@ from build123d.geometry import Color # Overloads -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color("blue"), (0, 0, 1, 1), id="name"), - pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"), - pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"), -]) +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color("blue"), (0, 0, 1, 1), id="name"), + pytest.param(Color("blue", alpha=0.5), (0, 0, 1, 0.5), id="name + kw alpha"), + pytest.param(Color("blue", 0.5), (0, 0, 1, 0.5), id="name + alpha"), + ], +) def test_overload_name(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"), - pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"), - pytest.param(Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha"), - pytest.param(Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), (0.1, 0.2, 0.3, 0.5), id="kw rgba"), -]) + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color(0.0, 1.0, 0.0), (0, 1, 0, 1), id="rgb"), + pytest.param(Color(1.0, 1.0, 0.0, 0.5), (1, 1, 0, 0.5), id="rgba"), + pytest.param( + Color(1.0, 1.0, 0.0, alpha=0.5), (1, 1, 0, 0.5), id="rgb + kw alpha" + ), + pytest.param( + Color(red=0.1, green=0.2, blue=0.3, alpha=0.5), + (0.1, 0.2, 0.3, 0.5), + id="kw rgba", + ), + ], +) def test_overload_rgba(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code"), - pytest.param(Color(0x006692, 0x80), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), id="color_code + alpha"), - pytest.param(Color(0x006692, alpha=0x80), (0, 102 / 255, 146 / 255, 128 / 255), id="color_code + kw alpha"), - pytest.param(Color(color_code=0x996692, alpha=0xCC), (153 / 255, 102 / 255, 146 / 255, 204 / 255), id="kw color_code + alpha"), -]) + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param( + Color(0x996692), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), id="color_code" + ), + pytest.param( + Color(0x006692, 0x80), + (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), + id="color_code + alpha", + ), + pytest.param( + Color(0x006692, alpha=0x80), + (0, 102 / 255, 146 / 255, 128 / 255), + id="color_code + kw alpha", + ), + pytest.param( + Color(color_code=0x996692, alpha=0xCC), + (153 / 255, 102 / 255, 146 / 255, 204 / 255), + id="kw color_code + alpha", + ), + ], +) def test_overload_hex(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) -@pytest.mark.parametrize("color, expected", [ - pytest.param(Color((0.1,)), (0.1, 1.0, 1.0, 1.0), id="tuple r"), - pytest.param(Color((0.1, 0.2)), (0.1, 0.2, 1.0, 1.0), id="tuple rg"), - pytest.param(Color((0.1, 0.2, 0.3)), (0.1, 0.2, 0.3, 1.0), id="tuple rgb"), - pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga"), - pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="kw tuple"), -]) + +@pytest.mark.parametrize( + "color, expected", + [ + pytest.param(Color((0.1,)), (0.1, 1.0, 1.0, 1.0), id="tuple r"), + pytest.param(Color((0.1, 0.2)), (0.1, 0.2, 1.0, 1.0), id="tuple rg"), + pytest.param(Color((0.1, 0.2, 0.3)), (0.1, 0.2, 0.3, 1.0), id="tuple rgb"), + pytest.param( + Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="tuple rbga" + ), + pytest.param(Color((0.1, 0.2, 0.3, 0.4)), (0.1, 0.2, 0.3, 0.4), id="kw tuple"), + ], +) def test_overload_tuple(color, expected): np.testing.assert_allclose(tuple(color), expected, 1e-5) + # ColorLikes -@pytest.mark.parametrize("color_like", [ - pytest.param(Quantity_ColorRGBA(1, 0, 0, 1), id="Quantity_ColorRGBA"), - pytest.param("red", id="name str"), - pytest.param("red ", id="name str whitespace"), - pytest.param(("red",), id="tuple name str"), - pytest.param(("red", 1), id="tuple name str + alpha"), - pytest.param("#ff0000", id="hex str rgb 24bit"), - pytest.param(" #ff0000 ", id="hex str rgb 24bit whitespace"), - pytest.param(("#ff0000",), id="tuple hex str rgb 24bit"), - pytest.param(("#ff0000", 1), id="tuple hex str rgb 24bit + alpha"), - pytest.param("#ff0000ff", id="hex str rgba 24bit"), - pytest.param(" #ff0000ff ", id="hex str rgba 24bit whitespace"), - pytest.param(("#ff0000ff",), id="tuple hex str rgba 24bit"), - pytest.param(("#ff0000ff", .6), id="tuple hex str rgba 24bit + alpha (not used)"), - pytest.param("#f00", id="hex str rgb 12bit"), - pytest.param(" #f00 ", id="hex str rgb 12bit whitespace"), - pytest.param(("#f00",), id="tuple hex str rgb 12bit"), - pytest.param(("#f00", 1), id="tuple hex str rgb 12bit + alpha"), - pytest.param("#f00f", id="hex str rgba 12bit"), - pytest.param(" #f00f ", id="hex str rgba 12bit whitespace"), - pytest.param(("#f00f",), id="tuple hex str rgba 12bit"), - pytest.param(("#f00f", .6), id="tuple hex str rgba 12bit + alpha (not used)"), - pytest.param(0xff0000, id="hex int"), - pytest.param((0xff0000), id="tuple hex int"), - pytest.param((0xff0000, 0xff), id="tuple hex int + alpha"), - pytest.param((1, 0, 0), id="tuple rgb int"), - pytest.param((1, 0, 0, 1), id="tuple rgba int"), - pytest.param((1., 0., 0.), id="tuple rgb float"), - pytest.param((1., 0., 0., 1.), id="tuple rgba float"), -]) +@pytest.mark.parametrize( + "color_like", + [ + pytest.param(Quantity_ColorRGBA(1, 0, 0, 1), id="Quantity_ColorRGBA"), + pytest.param("red", id="name str"), + pytest.param("red ", id="name str whitespace"), + pytest.param(("red",), id="tuple name str"), + pytest.param(("red", 1), id="tuple name str + alpha"), + pytest.param("#ff0000", id="hex str rgb 24bit"), + pytest.param(" #ff0000 ", id="hex str rgb 24bit whitespace"), + pytest.param(("#ff0000",), id="tuple hex str rgb 24bit"), + pytest.param(("#ff0000", 1), id="tuple hex str rgb 24bit + alpha"), + pytest.param("#ff0000ff", id="hex str rgba 24bit"), + pytest.param(" #ff0000ff ", id="hex str rgba 24bit whitespace"), + pytest.param(("#ff0000ff",), id="tuple hex str rgba 24bit"), + pytest.param( + ("#ff0000ff", 0.6), id="tuple hex str rgba 24bit + alpha (not used)" + ), + pytest.param("#f00", id="hex str rgb 12bit"), + pytest.param(" #f00 ", id="hex str rgb 12bit whitespace"), + pytest.param(("#f00",), id="tuple hex str rgb 12bit"), + pytest.param(("#f00", 1), id="tuple hex str rgb 12bit + alpha"), + pytest.param("#f00f", id="hex str rgba 12bit"), + pytest.param(" #f00f ", id="hex str rgba 12bit whitespace"), + pytest.param(("#f00f",), id="tuple hex str rgba 12bit"), + pytest.param(("#f00f", 0.6), id="tuple hex str rgba 12bit + alpha (not used)"), + pytest.param(0xFF0000, id="hex int"), + pytest.param((0xFF0000), id="tuple hex int"), + pytest.param((0xFF0000, 0xFF), id="tuple hex int + alpha"), + pytest.param((1, 0, 0), id="tuple rgb int"), + pytest.param((1, 0, 0, 1), id="tuple rgba int"), + pytest.param((1.0, 0.0, 0.0), id="tuple rgb float"), + pytest.param((1.0, 0.0, 0.0, 1.0), id="tuple rgba float"), + ], +) def test_color_likes(color_like): expected = (1, 0, 0, 1) np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) -@pytest.mark.parametrize("color_like, expected", [ - pytest.param(Color(), (1, 1, 1, 1), id="empty Color()"), - pytest.param(1., (1, 1, 1, 1), id="r float"), - pytest.param((1.,), (1, 1, 1, 1), id="tuple r float"), - pytest.param((1., 0.), (1, 0, 1, 1), id="tuple rg float"), -]) + +@pytest.mark.parametrize( + "color_like, expected", + [ + pytest.param(Color(), (1, 1, 1, 1), id="empty Color()"), + pytest.param(1.0, (1, 1, 1, 1), id="r float"), + pytest.param((1.0,), (1, 1, 1, 1), id="tuple r float"), + pytest.param((1.0, 0.0), (1, 0, 1, 1), id="tuple rg float"), + ], +) def test_color_likes_incomplete(color_like, expected): np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) -@pytest.mark.parametrize("color_like", [ - pytest.param(Quantity_ColorRGBA(1, 0, 0, 0.6), id="Quantity_ColorRGBA"), - pytest.param(("red", 0.6), id="tuple name str + alpha"), - pytest.param(("#ff0000", 0.6), id="tuple hex str rgb 24bit + alpha"), - pytest.param(("#ff000099"), id="tuple hex str rgba 24bit"), - pytest.param(("#f00", 0.6), id="tuple hex str rgb 12bit + alpha"), - pytest.param(("#f009"), id="tuple hex str rgba 12bit"), - pytest.param((0xff0000, 153), id="tuple hex int + alpha int"), - pytest.param((1., 0., 0., 0.6), id="tuple rbga float"), -]) + +@pytest.mark.parametrize( + "color_like", + [ + pytest.param(Quantity_ColorRGBA(1, 0, 0, 0.6), id="Quantity_ColorRGBA"), + pytest.param(("red", 0.6), id="tuple name str + alpha"), + pytest.param(("#ff0000", 0.6), id="tuple hex str rgb 24bit + alpha"), + pytest.param(("#ff000099"), id="tuple hex str rgba 24bit"), + pytest.param(("#f00", 0.6), id="tuple hex str rgb 12bit + alpha"), + pytest.param(("#f009"), id="tuple hex str rgba 12bit"), + pytest.param((0xFF0000, 153), id="tuple hex int + alpha int"), + pytest.param((1.0, 0.0, 0.0, 0.6), id="tuple rbga float"), + ], +) def test_color_likes_alpha(color_like): expected = (1, 0, 0, 0.6) np.testing.assert_allclose(tuple(Color(color_like)), expected, 1e-5) np.testing.assert_allclose(tuple(Color(color_like=color_like)), expected, 1e-5) + # Exceptions -@pytest.mark.parametrize("name", [ - pytest.param("build123d", id="invalid color name"), - pytest.param("#ffg", id="invalid rgb 12bit"), - pytest.param("#fffg", id="invalid rgba 12bit"), - pytest.param("#fffgg", id="invalid rgb 24bit"), - pytest.param("#fff00gg", id="invalid rgba 24bit"), - pytest.param("#ff", id="short rgb 12bit"), - pytest.param("#fffff", id="short rgb 24bit"), - pytest.param("#fffffff", id="short rgba 24bit"), - pytest.param("#fffffffff", id="long rgba 24bit"), -]) +@pytest.mark.parametrize( + "name", + [ + pytest.param("build123d", id="invalid color name"), + pytest.param("#ffg", id="invalid rgb 12bit"), + pytest.param("#fffg", id="invalid rgba 12bit"), + pytest.param("#fffgg", id="invalid rgb 24bit"), + pytest.param("#fff00gg", id="invalid rgba 24bit"), + pytest.param("#ff", id="short rgb 12bit"), + pytest.param("#fffff", id="short rgb 24bit"), + pytest.param("#fffffff", id="short rgba 24bit"), + pytest.param("#fffffffff", id="long rgba 24bit"), + ], +) def test_exceptions_color_name(name): with pytest.raises(Exception): Color(name) -@pytest.mark.parametrize("color_type", [ - pytest.param((dict({"name": "red", "alpha": 1},)), id="dict arg"), - pytest.param(("red", "blue"), id="str + str"), - pytest.param((1., "blue"), id="float + str order"), - pytest.param((1, "blue"), id="int + str order"), -]) + +@pytest.mark.parametrize( + "color_type", + [ + pytest.param( + ( + dict( + {"name": "red", "alpha": 1}, + ) + ), + id="dict arg", + ), + pytest.param(("red", "blue"), id="str + str"), + pytest.param((1.0, "blue"), id="float + str order"), + pytest.param((1, "blue"), id="int + str order"), + ], +) def test_exceptions_color_type(color_type): with pytest.raises(Exception): Color(*color_type) + # Methods def test_rgba_wrapped(): c = Color(1.0, 1.0, 0.0, 0.5) @@ -167,17 +235,87 @@ def test_rgba_wrapped(): assert c.wrapped.GetRGB().Blue() == 0.0 assert c.wrapped.Alpha() == 0.5 + def test_copy(): c = Color(0.1, 0.2, 0.3, alpha=0.4) c_copy = copy.copy(c) np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), rtol=1e-5) + def test_str_repr_is(): c = Color(1, 0, 0) assert str(c) == "Color: (1.0, 0.0, 0.0, 1.0) is 'RED'" assert repr(c) == "Color(1.0, 0.0, 0.0, 1.0)" + def test_str_repr_near(): c = Color(1, 0.5, 0) assert str(c) == "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'" assert repr(c) == "Color(1.0, 0.5, 0.0, 1.0)" + + +class TestColorCategoricalSet: + def test_returns_expected_number_of_colors(self): + colors = Color.categorical_set(5) + assert len(colors) == 5 + assert all(isinstance(c, Color) for c in colors) + + def test_colors_are_evenly_spaced_in_hue(self): + count = 8 + colors = Color.categorical_set(count) + hues = [colorsys.rgb_to_hls(*tuple(c)[:3])[0] for c in colors] + diffs = [(hues[(i + 1) % count] - hues[i]) % 1.0 for i in range(count)] + avg_diff = sum(diffs) / len(diffs) + assert all(math.isclose(d, avg_diff, rel_tol=1e-2) for d in diffs) + + def test_starting_hue_as_float(self): + (r, g, b, _) = tuple(Color.categorical_set(1, starting_hue=0.25)[0]) + h = colorsys.rgb_to_hls(r, g, b)[0] + assert math.isclose(h, 0.25, rel_tol=0.05) + + def test_starting_hue_as_int_hex(self): + # Blue (0x0000FF) should be valid and return a Color + c = Color.categorical_set(1, starting_hue=0x0000FF)[0] + assert isinstance(c, Color) + + def test_starting_hue_invalid_type(self): + with pytest.raises(TypeError): + Color.categorical_set(3, starting_hue="invalid") + + def test_starting_hue_out_of_range(self): + with pytest.raises(ValueError): + Color.categorical_set(3, starting_hue=1.5) + with pytest.raises(ValueError): + Color.categorical_set(3, starting_hue=-0.1) + + def test_starting_hue_negative_int(self): + with pytest.raises(ValueError): + Color.categorical_set(3, starting_hue=-1) + + def test_constant_alpha_applied(self): + colors = Color.categorical_set(3, alpha=0.7) + for c in colors: + (_, _, _, a) = tuple(c) + assert math.isclose(a, 0.7, rel_tol=1e-6) + + def test_iterable_alpha_applied(self): + alphas = (0.1, 0.5, 0.9) + colors = Color.categorical_set(3, alpha=alphas) + for a, c in zip(alphas, colors): + (_, _, _, returned_alpha) = tuple(c) + assert math.isclose(a, returned_alpha, rel_tol=1e-6) + + def test_iterable_alpha_length_mismatch(self): + with pytest.raises(ValueError): + Color.categorical_set(4, alpha=[0.5, 0.7]) + + def test_hues_wrap_around(self): + colors = Color.categorical_set(10, starting_hue=0.95) + hues = [colorsys.rgb_to_hls(*tuple(c)[:3])[0] for c in colors] + assert all(0.0 <= h <= 1.0 for h in hues) + + def test_alpha_defaults_to_one(self): + colors = Color.categorical_set(4) + for c in colors: + (_, _, _, a) = tuple(c) + assert math.isclose(a, 1.0, rel_tol=1e-6)