Add ColorLike and update Color overloads accordingly

- add css3 color support through webcolors
- replace color_tuple
- restructure input branching
This commit is contained in:
Jonathan Wagenet 2025-08-11 20:43:10 -04:00
parent f59f127b19
commit 377ec3a40b
3 changed files with 213 additions and 87 deletions

View file

@ -47,6 +47,7 @@ dependencies = [
"trianglesolver", "trianglesolver",
"sympy", "sympy",
"scipy", "scipy",
"webcolors ~= 24.8.0",
] ]
[project.urls] [project.urls]

View file

@ -44,6 +44,7 @@ from math import degrees, isclose, log10, pi, radians
from typing import TYPE_CHECKING, Any, TypeAlias, overload from typing import TYPE_CHECKING, Any, TypeAlias, overload
import numpy as np import numpy as np
import webcolors # type: ignore
from OCP.Bnd import Bnd_Box, Bnd_OBB from OCP.Bnd import Bnd_Box, Bnd_OBB
from OCP.BRep import BRep_Tool from OCP.BRep import BRep_Tool
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
@ -1146,22 +1147,33 @@ class Color:
""" """
@overload @overload
def __init__(self, q_color: Quantity_ColorRGBA): def __init__(self, color_like: ColorLike):
"""Color from OCCT color object """Color from ColorLike
Args: Args:
name (Quantity_ColorRGBA): q_color color_like (ColorLike):
name, ex: "red",
name + alpha, ex: ("red", 0.5),
rgb, ex: (1., 0., 0.),
rgb + alpha, ex: (1., 0., 0., 0.5),
hex, ex: 0xff0000,
hex + alpha, ex: (0xff0000, 0x80),
Quantity_ColorRGBA
""" """
@overload @overload
def __init__(self, name: str, alpha: float = 1.0): def __init__(self, name: str, alpha: float = 1.0):
"""Color from name """Color from name
`CSS3 Color Names
<https://en.wikipedia.org/wiki/Web_colors#Extended_colors>`
`OCCT Color Names `OCCT Color Names
<https://dev.opencascade.org/doc/refman/html/_quantity___name_of_color_8hxx.html>`_ <https://dev.opencascade.org/doc/refman/html/_quantity___name_of_color_8hxx.html>`_
Args: Args:
name (str): color, e.g. "blue" name (str): color, e.g. "blue"
alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0
""" """
@overload @overload
@ -1172,15 +1184,7 @@ class Color:
red (float): 0.0 <= red <= 1.0 red (float): 0.0 <= red <= 1.0
green (float): 0.0 <= green <= 1.0 green (float): 0.0 <= green <= 1.0
blue (float): 0.0 <= blue <= 1.0 blue (float): 0.0 <= blue <= 1.0
alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 0.0. alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0
"""
@overload
def __init__(self, color_tuple: tuple[float]):
"""Color from a 3 or 4 tuple of float values
Args:
color_tuple (tuple[float]): _description_
""" """
@overload @overload
@ -1193,69 +1197,78 @@ class Color:
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# pylint: disable=too-many-branches self.wrapped = None
red, green, blue, alpha, color_tuple, name, color_code, q_color = ( self.iter_index = 0
1.0, red, green, blue, alpha, name, color_code = (1.0, 1.0, 1.0, 1.0, None, None)
1.0, default_rgb = (red, green, blue, alpha)
1.0,
1.0,
None,
None,
None,
None,
)
if len(args) == 1 and isinstance(args[0], tuple):
red, green, blue, alpha = args[0] + (1.0,) * (4 - len(args[0]))
elif len(args) == 1 or len(args) == 2:
if isinstance(args[0], Quantity_ColorRGBA):
q_color = args[0]
elif isinstance(args[0], int):
color_code = args[0]
alpha = args[1] if len(args) == 2 else 0xFF
elif isinstance(args[0], str):
name = args[0]
if len(args) == 2:
alpha = args[1]
elif len(args) >= 3:
red, green, blue = args[0:3] # pylint: disable=unbalanced-tuple-unpacking
if len(args) == 4:
alpha = args[3]
# Conform inputs to complete color_like tuples
# color_like does not use other kwargs or args, but benefits from conformity
color_like = kwargs.get("color_like", None)
if color_like is not None:
args = (color_like,)
if args:
args = args[0] if isinstance(args[0], tuple) else args
# Fills missing defaults from b if a is short
def fill_defaults(a, b):
return tuple(a[i] if i < len(a) else b[i] for i in range(len(b)))
if args:
if len(args) >= 3:
red, green, blue, alpha = fill_defaults(args, default_rgb)
else:
match args[0]:
case Quantity_ColorRGBA():
# Nothing else to do here
self.wrapped = args[0]
return
case str():
name, alpha = fill_defaults(args, (name, alpha))
case int():
color_code, alpha = fill_defaults(args, (color_code, alpha))
case float():
red, green, blue, alpha = fill_defaults(args, default_rgb)
case _:
raise TypeError(f"Unsupported color definition: {args}")
# Replace positional values with kwargs unless from color_like
if color_like is None:
name = kwargs.get("name", name)
color_code = kwargs.get("color_code", color_code) color_code = kwargs.get("color_code", color_code)
red = kwargs.get("red", red) red = kwargs.get("red", red)
green = kwargs.get("green", green) green = kwargs.get("green", green)
blue = kwargs.get("blue", blue) blue = kwargs.get("blue", blue)
color_tuple = kwargs.get("color_tuple", color_tuple)
if color_code is None:
alpha = kwargs.get("alpha", alpha) alpha = kwargs.get("alpha", alpha)
if name:
color_format = (name, alpha)
elif color_code:
color_format = (color_code, alpha)
else: else:
alpha = kwargs.get("alpha", alpha) color_format = (red, green, blue, alpha)
alpha = alpha / 255
if color_code is not None and isinstance(color_code, int): # Convert color_format to rgb
red, remainder = divmod(color_code, 256**2) match color_format:
green, blue = divmod(remainder, 256) case (name, a) if isinstance(name, str) and isinstance(a, (float, int)):
red = red / 255 red, green, blue = Color._rgb_from_str(name)
green = green / 255 alpha = a
blue = blue / 255 case (hexa, a) if isinstance(hexa, int) and isinstance(a, (float, int)):
red, green, blue = Color._rgb_from_int(hexa)
if a != 1:
# alpha == 1 is special case as default, don't divide
alpha = a / 0xFF
case (red, green, blue, alpha) if all(
isinstance(c, (int, float)) for c in (red, green, blue, alpha)
):
pass
case _:
raise TypeError(f"Unsupported color definition: {color_format}")
if color_tuple is not None: if not self.wrapped:
red, green, blue, alpha = color_tuple + (1.0,) * (4 - len(color_tuple))
if q_color is not None:
self.wrapped = q_color
elif name:
self.wrapped = Quantity_ColorRGBA()
exists = Quantity_ColorRGBA.ColorFromName_s(args[0], self.wrapped)
if not exists:
raise ValueError(f"Unknown color name: {name}")
self.wrapped.SetAlpha(alpha)
else:
self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha) self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha)
self.iter_index = 0
def __iter__(self): def __iter__(self):
"""Initialize to beginning""" """Initialize to beginning"""
self.iter_index = 0 self.iter_index = 0
@ -1292,14 +1305,60 @@ class Color:
def __str__(self) -> str: def __str__(self) -> str:
"""Generate string""" """Generate string"""
rgb = self.wrapped.GetRGB()
rgb = (rgb.Red(), rgb.Green(), rgb.Blue())
try:
name = webcolors.rgb_to_name([int(c * 255) for c in rgb])
qualifier = "is"
except ValueError:
# This still uses OCCT X11 colors instead of css3
quantity_color_enum = self.wrapped.GetRGB().Name() quantity_color_enum = self.wrapped.GetRGB().Name()
quantity_color_str = Quantity_Color.StringName_s(quantity_color_enum) name = Quantity_Color.StringName_s(quantity_color_enum)
return f"Color: {str(tuple(self))} ~ {quantity_color_str}" qualifier = "near"
return f"Color: {str(tuple(self))} {qualifier} {name.upper()!r}"
def __repr__(self) -> str: def __repr__(self) -> str:
"""Color repr""" """Color repr"""
return f"Color{str(tuple(self))}" return f"Color{str(tuple(self))}"
@staticmethod
def _rgb_from_int(triplet: int) -> tuple[float, float, float]:
red, remainder = divmod(triplet, 256**2)
green, blue = divmod(remainder, 256)
return red / 255, green / 255, blue / 255
@staticmethod
def _rgb_from_str(name: str) -> tuple:
if "#" not in name:
try:
# Use css3 color names by default
triplet = webcolors.name_to_rgb(name)
except ValueError as exc:
# Fall back to OCCT/X11 color names
color = Quantity_Color()
exists = Quantity_Color.ColorFromName_s(name, color)
if not exists:
raise ValueError(
f"{name!r} is not defined as a named color in CSS3 or OCCT/X11"
) from exc
return (color.Red(), color.Green(), color.Blue())
else:
triplet = webcolors.hex_to_rgb(name)
return tuple(i / 255 for i in tuple(triplet))
ColorLike: TypeAlias = (
str # name, ex: "red"
| tuple[str, float | int] # name + alpha, ex: ("red", 0.5)
| tuple[float | int, float | int, float | int] # rgb, ex: (1, 0, 0)
| tuple[
float | int, float | int, float | int, float | int
] # rgb + alpha, ex: (1, 0, 0, 0.5)
| int # hex, ex: 0xff0000
| tuple[int, int] # hex + alpha, ex: (0xff0000, 0x80)
| Quantity_ColorRGBA # OCP color
)
class GeomEncoder(json.JSONEncoder): class GeomEncoder(json.JSONEncoder):
""" """
@ -1346,7 +1405,7 @@ class GeomEncoder(json.JSONEncoder):
return {"Color": tuple(o)} return {"Color": tuple(o)}
if isinstance(o, Location): if isinstance(o, Location):
tup = tuple(o) tup = tuple(o)
return {f"Location": (tuple(tup[0]), tuple(tup[1]))} return {"Location": (tuple(tup[0]), tuple(tup[1]))}
if isinstance(o, Plane): if isinstance(o, Plane):
return {"Plane": (tuple(o.origin), tuple(o.x_dir), tuple(o.z_dir))} return {"Plane": (tuple(o.origin), tuple(o.x_dir), tuple(o.z_dir))}
if isinstance(o, Vector): if isinstance(o, Vector):

View file

@ -31,9 +31,11 @@ import unittest
import numpy as np import numpy as np
from build123d.geometry import Color from build123d.geometry import Color
from OCP.Quantity import Quantity_ColorRGBA
class TestColor(unittest.TestCase): class TestColor(unittest.TestCase):
# name + alpha overload
def test_name1(self): def test_name1(self):
c = Color("blue") c = Color("blue")
np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
@ -46,6 +48,7 @@ class TestColor(unittest.TestCase):
c = Color("blue", 0.5) c = Color("blue", 0.5)
np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5) np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
# red + green + blue + alpha overload
def test_rgb0(self): def test_rgb0(self):
c = Color(0.0, 1.0, 0.0) c = Color(0.0, 1.0, 0.0)
np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 1e-5) np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 1e-5)
@ -65,14 +68,7 @@ class TestColor(unittest.TestCase):
c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5) 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) np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.5), 1e-5)
def test_bad_color_name(self): # hex (int) + alpha overload
with self.assertRaises(ValueError):
Color("build123d")
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_hex(self): def test_hex(self):
c = Color(0x996692) c = Color(0x996692)
np.testing.assert_allclose( np.testing.assert_allclose(
@ -98,6 +94,11 @@ class TestColor(unittest.TestCase):
c = Color(0, 0, 1, 1) c = Color(0, 0, 1, 1)
np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5) np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
# 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): def test_copy(self):
c = Color(0.1, 0.2, 0.3, alpha=0.4) c = Color(0.1, 0.2, 0.3, alpha=0.4)
c_copy = copy.copy(c) c_copy = copy.copy(c)
@ -105,9 +106,13 @@ class TestColor(unittest.TestCase):
def test_str_repr(self): def test_str_repr(self):
c = Color(1, 0, 0) c = Color(1, 0, 0)
self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) ~ RED") 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)") 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): def test_tuple(self):
c = Color((0.1,)) c = Color((0.1,))
np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5) np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5)
@ -117,9 +122,70 @@ class TestColor(unittest.TestCase):
np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 1.0), 1e-5) 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)) 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) np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4)) 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) 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", 1),
"#ff0000",
("#ff0000",),
("#ff0000", 1),
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 = [
(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),
(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")
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__": if __name__ == "__main__":
unittest.main() unittest.main()