From 5d84002aa5211ace05a39b4891df052a5a180a83 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 12 Nov 2025 10:37:45 -0500 Subject: [PATCH 1/3] Add Color support for RGBA hex string --- src/build123d/geometry.py | 32 ++++++++++++++++++++++++----- tests/test_direct_api/test_color.py | 30 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index e4c0eeb..5952389 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1160,8 +1160,8 @@ class Color: Args: color_like (ColorLike): - name, ex: "red", - name + alpha, ex: ("red", 0.5), + name, ex: "red" or "#ff0000", + name + alpha, ex: ("red", 0.5) or "#ff000080", rgb, ex: (1., 0., 0.), rgb + alpha, ex: (1., 0., 0., 0.5), hex, ex: 0xff0000, @@ -1172,7 +1172,7 @@ class Color: @overload def __init__(self, name: str, alpha: float = 1.0): - """Color from name + """Color from name or hexadecimal string `CSS3 Color Names ` @@ -1180,8 +1180,10 @@ class Color: `OCCT Color Names `_ + Hexadecimal string may be RGB or RGBA format with leading "#" + Args: - name (str): color, e.g. "blue" + name (str): color, e.g. "blue" or "#0000ff"" alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0 """ @@ -1237,6 +1239,27 @@ class Color: return case str(): name, alpha = fill_defaults(args, (name, alpha)) + name = name.strip() + if "#" in name: + # extract alpha from hex string + hex_a = format(int(alpha * 255), "x") + if len(name) == 5: + hex_a = name[4] * 2 + name = name[:4] + elif len(name) == 9: + hex_a = name[7:9] + name = name[:7] + elif len(name) not in [4, 5, 7, 9]: + raise ValueError( + f'"{name}" is not a valid hexadecimal color value.' + ) + try: + if hex_a: + alpha = int(hex_a, 16) / 0xFF + except ValueError as ex: + raise ValueError( + f"Invald alpha hex string: {hex_a}" + ) from ex case int(): color_code, alpha = fill_defaults(args, (color_code, alpha)) case float(): @@ -1340,7 +1363,6 @@ class Color: @staticmethod def _rgb_from_str(name: str) -> tuple: - name = name.strip() if "#" not in name: try: # Use css3 color names by default diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 62c26bf..aab9254 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -137,6 +137,18 @@ class TestColor(unittest.TestCase): " #ff0000 ", ("#ff0000",), ("#ff0000", 1), + "#ff0000ff", + " #ff0000ff ", + ("#ff0000ff",), + ("#ff0000ff", .6), + "#f00", + " #f00 ", + ("#f00",), + ("#f00", 1), + "#f00f", + " #f00f ", + ("#f00f",), + ("#f00f", .6), 0xff0000, (0xff0000), (0xff0000, 0xff), @@ -164,6 +176,9 @@ class TestColor(unittest.TestCase): Quantity_ColorRGBA(1, 0, 0, 0.6), ("red", 0.6), ("#ff0000", 0.6), + ("#ff000099"), + ("#f00", 0.6), + ("#f009"), (0xff0000, 153), (1., 0., 0., 0.6) ] @@ -177,6 +192,21 @@ class TestColor(unittest.TestCase): with self.assertRaises(ValueError): Color("build123d") + with self.assertRaises(ValueError): + Color("#ff") + + with self.assertRaises(ValueError): + Color("#ffg") + + with self.assertRaises(ValueError): + Color("#fffff") + + with self.assertRaises(ValueError): + Color("#fffg") + + with self.assertRaises(ValueError): + Color("#fff00gg") + def test_bad_color_type(self): with self.assertRaises(TypeError): Color(dict({"name": "red", "alpha": 1})) From cc34b5a743f5983825988b9e28c46568b4aacd86 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 12 Nov 2025 12:18:30 -0500 Subject: [PATCH 2/3] Convert to pytest with parameterization and test ids --- tests/test_direct_api/test_color.py | 317 ++++++++++++---------------- 1 file changed, 140 insertions(+), 177 deletions(-) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index aab9254..4fa91fe 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -27,198 +27,161 @@ license: """ import copy -import unittest import numpy as np -from build123d.geometry import Color +import pytest + from OCP.Quantity import Quantity_ColorRGBA +from build123d.geometry import Color -class TestColor(unittest.TestCase): - # name + alpha overload - def test_name1(self): - c = Color("blue") - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) +# 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"), +]) +def test_overload_name(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - def test_name2(self): - c = Color("blue", alpha=0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 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"), +]) +def test_overload_rgba(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - def test_name3(self): - c = Color("blue", 0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 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"), +]) +def test_overload_hex(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - # red + green + blue + alpha overload - def test_rgb0(self): - c = Color(0.0, 1.0, 0.0) - np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 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"), +]) +def test_overload_tuple(color, expected): + np.testing.assert_allclose(tuple(color), expected, 1e-5) - def test_rgba1(self): - c = Color(1.0, 1.0, 0.0, 0.5) - self.assertEqual(c.wrapped.GetRGB().Red(), 1.0) - self.assertEqual(c.wrapped.GetRGB().Green(), 1.0) - self.assertEqual(c.wrapped.GetRGB().Blue(), 0.0) - self.assertEqual(c.wrapped.Alpha(), 0.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"), +]) +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) - def test_rgba2(self): - c = Color(1.0, 1.0, 0.0, alpha=0.5) - np.testing.assert_allclose(tuple(c), (1, 1, 0, 0.5), 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"), +]) +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) - def test_rgba3(self): - c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.5), 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"), +]) +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) - # hex (int) + alpha overload - def test_hex(self): - c = Color(0x996692) - np.testing.assert_allclose( - tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 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"), +]) +def test_exceptions_color_name(name): + with pytest.raises(Exception): + Color(name) - c = Color(0x006692, 0x80) - np.testing.assert_allclose( - tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5 - ) +@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"), +]) +def test_exceptions_color_type(color_type): + with pytest.raises(Exception): + Color(*color_type) - c = Color(0x006692, alpha=0x80) - np.testing.assert_allclose(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 1e-5) +# Methods +def test_rgba_wrapped(): + c = Color(1.0, 1.0, 0.0, 0.5) + assert c.wrapped.GetRGB().Red() == 1.0 + assert c.wrapped.GetRGB().Green() == 1.0 + assert c.wrapped.GetRGB().Blue() == 0.0 + assert c.wrapped.Alpha() == 0.5 - c = Color(color_code=0x996692, alpha=0xCC) - np.testing.assert_allclose( - tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5 - ) +def test_to_tuple(): + c = Color("blue", alpha=0.5) + np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), rtol=1e-5) - c = Color(0.0, 0.0, 1.0, 1.0) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-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) - c = Color(0, 0, 1, 1) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 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)" - # Methods - def test_to_tuple(self): - c = Color("blue", alpha=0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5) - - def test_copy(self): - 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), 1e-5) - - def test_str_repr(self): - c = Color(1, 0, 0) - self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) is 'RED'") - self.assertEqual(repr(c), "Color(1.0, 0.0, 0.0, 1.0)") - - c = Color(1, .5, 0) - self.assertEqual(str(c), "Color: (1.0, 0.5, 0.0, 1.0) near 'DARKGOLDENROD1'") - self.assertEqual(repr(c), "Color(1.0, 0.5, 0.0, 1.0)") - - def test_tuple(self): - c = Color((0.1,)) - np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5) - c = Color((0.1, 0.2)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 1.0, 1.0), 1e-5) - c = Color((0.1, 0.2, 0.3)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 1.0), 1e-5) - c = Color((0.1, 0.2, 0.3, 0.4)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5) - c = Color(color_like=(0.1, 0.2, 0.3, 0.4)) - np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5) - - # color_like overload - def test_color_like(self): - red_color_likes = [ - Quantity_ColorRGBA(1, 0, 0, 1), - "red", - "red ", - ("red",), - ("red", 1), - "#ff0000", - " #ff0000 ", - ("#ff0000",), - ("#ff0000", 1), - "#ff0000ff", - " #ff0000ff ", - ("#ff0000ff",), - ("#ff0000ff", .6), - "#f00", - " #f00 ", - ("#f00",), - ("#f00", 1), - "#f00f", - " #f00f ", - ("#f00f",), - ("#f00f", .6), - 0xff0000, - (0xff0000), - (0xff0000, 0xff), - (1, 0, 0), - (1, 0, 0, 1), - (1., 0., 0.), - (1., 0., 0., 1.) - ] - expected = (1, 0, 0, 1) - for cl in red_color_likes: - np.testing.assert_allclose(tuple(Color(cl)), expected, 1e-5) - np.testing.assert_allclose(tuple(Color(color_like=cl)), expected, 1e-5) - - incomplete_color_likes = [ - (Color(), (1, 1, 1, 1)), - (1., (1, 1, 1, 1)), - ((1.,), (1, 1, 1, 1)), - ((1., 0.), (1, 0, 1, 1)), - ] - for cl, expected in incomplete_color_likes: - np.testing.assert_allclose(tuple(Color(cl)), expected, 1e-5) - np.testing.assert_allclose(tuple(Color(color_like=cl)), expected, 1e-5) - - alpha_color_likes = [ - Quantity_ColorRGBA(1, 0, 0, 0.6), - ("red", 0.6), - ("#ff0000", 0.6), - ("#ff000099"), - ("#f00", 0.6), - ("#f009"), - (0xff0000, 153), - (1., 0., 0., 0.6) - ] - expected = (1, 0, 0, 0.6) - for cl in alpha_color_likes: - np.testing.assert_allclose(tuple(Color(cl)), expected, 1e-5) - np.testing.assert_allclose(tuple(Color(color_like=cl)), expected, 1e-5) - - # Exceptions - def test_bad_color_name(self): - with self.assertRaises(ValueError): - Color("build123d") - - with self.assertRaises(ValueError): - Color("#ff") - - with self.assertRaises(ValueError): - Color("#ffg") - - with self.assertRaises(ValueError): - Color("#fffff") - - with self.assertRaises(ValueError): - Color("#fffg") - - with self.assertRaises(ValueError): - Color("#fff00gg") - - def test_bad_color_type(self): - with self.assertRaises(TypeError): - Color(dict({"name": "red", "alpha": 1})) - - with self.assertRaises(TypeError): - Color("red", "blue") - - with self.assertRaises(TypeError): - Color(1., "blue") - - with self.assertRaises(TypeError): - Color(1, "blue") - -if __name__ == "__main__": - unittest.main() +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)" From 083cb1611cd4db739bd1b2e8239cfca074fd8399 Mon Sep 17 00:00:00 2001 From: Jonathan Wagenet Date: Wed, 12 Nov 2025 12:29:48 -0500 Subject: [PATCH 3/3] Remove depreciated Color.to_tuple --- src/build123d/geometry.py | 10 ---------- tests/test_direct_api/test_color.py | 4 ---- 2 files changed, 14 deletions(-) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 5952389..e17d049 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1319,16 +1319,6 @@ class Color: self.iter_index += 1 return value - def to_tuple(self): - """Value as tuple""" - warnings.warn( - "to_tuple is deprecated and will be removed in a future version. " - "Use 'tuple(Color)' instead.", - DeprecationWarning, - stacklevel=2, - ) - return tuple(self) - def __copy__(self) -> Color: """Return copy of self""" return Color(*tuple(self)) diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py index 4fa91fe..650f5c9 100644 --- a/tests/test_direct_api/test_color.py +++ b/tests/test_direct_api/test_color.py @@ -167,10 +167,6 @@ def test_rgba_wrapped(): assert c.wrapped.GetRGB().Blue() == 0.0 assert c.wrapped.Alpha() == 0.5 -def test_to_tuple(): - c = Color("blue", alpha=0.5) - np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), rtol=1e-5) - def test_copy(): c = Color(0.1, 0.2, 0.3, alpha=0.4) c_copy = copy.copy(c)