mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -08:00
Add ColorLike and update Color overloads accordingly
- add css3 color support through webcolors - replace color_tuple - restructure input branching
This commit is contained in:
parent
f59f127b19
commit
377ec3a40b
3 changed files with 213 additions and 87 deletions
|
|
@ -47,6 +47,7 @@ dependencies = [
|
|||
"trianglesolver",
|
||||
"sympy",
|
||||
"scipy",
|
||||
"webcolors ~= 24.8.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from math import degrees, isclose, log10, pi, radians
|
|||
from typing import TYPE_CHECKING, Any, TypeAlias, overload
|
||||
|
||||
import numpy as np
|
||||
import webcolors # type: ignore
|
||||
from OCP.Bnd import Bnd_Box, Bnd_OBB
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
|
@ -1146,22 +1147,33 @@ class Color:
|
|||
"""
|
||||
|
||||
@overload
|
||||
def __init__(self, q_color: Quantity_ColorRGBA):
|
||||
"""Color from OCCT color object
|
||||
def __init__(self, color_like: ColorLike):
|
||||
"""Color from ColorLike
|
||||
|
||||
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
|
||||
def __init__(self, name: str, alpha: float = 1.0):
|
||||
"""Color from name
|
||||
|
||||
`CSS3 Color Names
|
||||
<https://en.wikipedia.org/wiki/Web_colors#Extended_colors>`
|
||||
|
||||
`OCCT Color Names
|
||||
<https://dev.opencascade.org/doc/refman/html/_quantity___name_of_color_8hxx.html>`_
|
||||
|
||||
Args:
|
||||
name (str): color, e.g. "blue"
|
||||
alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0
|
||||
"""
|
||||
|
||||
@overload
|
||||
|
|
@ -1172,15 +1184,7 @@ class Color:
|
|||
red (float): 0.0 <= red <= 1.0
|
||||
green (float): 0.0 <= green <= 1.0
|
||||
blue (float): 0.0 <= blue <= 1.0
|
||||
alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 0.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_
|
||||
alpha (float, optional): 0.0 <= alpha <= 1.0. Defaults to 1.0
|
||||
"""
|
||||
|
||||
@overload
|
||||
|
|
@ -1193,69 +1197,78 @@ class Color:
|
|||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# pylint: disable=too-many-branches
|
||||
red, green, blue, alpha, color_tuple, name, color_code, q_color = (
|
||||
1.0,
|
||||
1.0,
|
||||
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]
|
||||
self.wrapped = None
|
||||
self.iter_index = 0
|
||||
red, green, blue, alpha, name, color_code = (1.0, 1.0, 1.0, 1.0, None, None)
|
||||
default_rgb = (red, green, blue, alpha)
|
||||
|
||||
# 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)
|
||||
red = kwargs.get("red", red)
|
||||
green = kwargs.get("green", green)
|
||||
blue = kwargs.get("blue", blue)
|
||||
color_tuple = kwargs.get("color_tuple", color_tuple)
|
||||
|
||||
if color_code is None:
|
||||
alpha = kwargs.get("alpha", alpha)
|
||||
|
||||
if name:
|
||||
color_format = (name, alpha)
|
||||
elif color_code:
|
||||
color_format = (color_code, alpha)
|
||||
else:
|
||||
alpha = kwargs.get("alpha", alpha)
|
||||
alpha = alpha / 255
|
||||
color_format = (red, green, blue, alpha)
|
||||
|
||||
if color_code is not None and isinstance(color_code, int):
|
||||
red, remainder = divmod(color_code, 256**2)
|
||||
green, blue = divmod(remainder, 256)
|
||||
red = red / 255
|
||||
green = green / 255
|
||||
blue = blue / 255
|
||||
# Convert color_format to rgb
|
||||
match color_format:
|
||||
case (name, a) if isinstance(name, str) and isinstance(a, (float, int)):
|
||||
red, green, blue = Color._rgb_from_str(name)
|
||||
alpha = a
|
||||
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:
|
||||
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:
|
||||
if not self.wrapped:
|
||||
self.wrapped = Quantity_ColorRGBA(red, green, blue, alpha)
|
||||
|
||||
self.iter_index = 0
|
||||
|
||||
def __iter__(self):
|
||||
"""Initialize to beginning"""
|
||||
self.iter_index = 0
|
||||
|
|
@ -1292,14 +1305,60 @@ class Color:
|
|||
|
||||
def __str__(self) -> str:
|
||||
"""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_str = Quantity_Color.StringName_s(quantity_color_enum)
|
||||
return f"Color: {str(tuple(self))} ~ {quantity_color_str}"
|
||||
name = Quantity_Color.StringName_s(quantity_color_enum)
|
||||
qualifier = "near"
|
||||
return f"Color: {str(tuple(self))} {qualifier} {name.upper()!r}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Color repr"""
|
||||
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):
|
||||
"""
|
||||
|
|
@ -1346,7 +1405,7 @@ class GeomEncoder(json.JSONEncoder):
|
|||
return {"Color": tuple(o)}
|
||||
if isinstance(o, Location):
|
||||
tup = tuple(o)
|
||||
return {f"Location": (tuple(tup[0]), tuple(tup[1]))}
|
||||
return {"Location": (tuple(tup[0]), tuple(tup[1]))}
|
||||
if isinstance(o, Plane):
|
||||
return {"Plane": (tuple(o.origin), tuple(o.x_dir), tuple(o.z_dir))}
|
||||
if isinstance(o, Vector):
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ import unittest
|
|||
|
||||
import numpy as np
|
||||
from build123d.geometry import Color
|
||||
from OCP.Quantity import Quantity_ColorRGBA
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -46,6 +48,7 @@ class TestColor(unittest.TestCase):
|
|||
c = Color("blue", 0.5)
|
||||
np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 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)
|
||||
|
|
@ -65,14 +68,7 @@ class TestColor(unittest.TestCase):
|
|||
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)
|
||||
|
||||
def test_bad_color_name(self):
|
||||
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)
|
||||
|
||||
# hex (int) + alpha overload
|
||||
def test_hex(self):
|
||||
c = Color(0x996692)
|
||||
np.testing.assert_allclose(
|
||||
|
|
@ -98,6 +94,11 @@ class TestColor(unittest.TestCase):
|
|||
c = Color(0, 0, 1, 1)
|
||||
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):
|
||||
c = Color(0.1, 0.2, 0.3, alpha=0.4)
|
||||
c_copy = copy.copy(c)
|
||||
|
|
@ -105,9 +106,13 @@ class TestColor(unittest.TestCase):
|
|||
|
||||
def test_str_repr(self):
|
||||
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)")
|
||||
|
||||
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)
|
||||
|
|
@ -117,9 +122,70 @@ class TestColor(unittest.TestCase):
|
|||
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_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)
|
||||
|
||||
# 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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue