# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# LibFile: pybosl2/color.py
# Colour conversions and operators for the fluent object API.
# Uses Python's standard-library ``colorsys`` for HLS and HSV → RGB
# conversions. The :class:`Colorable` mixin provides the fluent colour
# operators consumed by :class:`~pybosl2.shapes3d.Bosl2Solid`.
# Opacity is applied by chaining :meth:`~Colorable.ghost` after the
# colour method rather than through an alpha parameter — matching how
# OpenSCAD's ``%`` (ghost/background) modifier works.
#
# FileSummary: Color
# DocCategory: Foundational
# FileGroup: BOSL2
"""Colour operators (Colorable mixin) via Python's colorsys module."""
from __future__ import annotations
import colorsys
import random
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from pybosl2.exceptions import Bosl2ValueError
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Self
from pybosl2._shape import BaseShape as Bosl2Shape
__all__ = ["rainbow", "rainbow_colors", "Colorable", "Color"]
# Colour-name lookup is delegated to the ``webcolors`` library (Python CSS3
# names). Hex parsing and RGBA construction are handled locally.
# ---------------------------------------------------------------------------
# rainbow
# ---------------------------------------------------------------------------
[docs]
def rainbow_colors(
sides: int,
stride: int = 1,
maxhues: int | None = None,
shuffle: bool = False,
seed: int | None = None,
) -> list[list[float]]:
"""Generate *sides* ``[R, G, B]`` colours stepped around the hue wheel.
Equivalent to BOSL2's ``rainbow()`` colour generation without applying the
colours to objects. Uses :func:`colorsys.hsv_to_rgb` internally.
Args:
sides: How many colours to generate.
stride: Consecutive colours stride this many steps around the wheel.
maxhues: Cap the number of distinct hues (default: *sides*).
shuffle: Shuffle the hue order before generating colours.
seed: Seed for the shuffle operation.
Returns:
A list of ``[R, G, B]`` lists, each with values 0..1.
Examples:
.. pythonscad-example::
from pybosl2.color import rainbow_colors
from pybosl2.shapes3d import cuboid
cols = rainbow_colors(3)
a = cuboid([5, 5, 10]).color(cols[0])
b = cuboid([5, 5, 10]).color(cols[1]).right(8)
c = cuboid([5, 5, 10]).color(cols[2]).right(16)
(a | b | c).show()
"""
if sides <= 0:
return []
mh = maxhues if maxhues is not None else sides
huestep = 360 / mh
hues = [(i * huestep + i * 360 / stride) % 360 for i in range(sides)]
if shuffle:
random.Random(seed).shuffle(hues)
return [list(colorsys.hsv_to_rgb(hue / 360, 1.0, 1.0)) for hue in hues]
[docs]
def rainbow(
items: Sequence[Bosl2Shape],
stride: int = 1,
maxhues: int | None = None,
shuffle: bool = False,
seed: int | None = None,
) -> list[Bosl2Shape]:
"""Colour each object in *items* a different hue.
Each item must be a :class:`~pybosl2._shape.Bosl2Shape` (a
:class:`~pybosl2.shapes2d.Bosl2Shape2D` or
:class:`~pybosl2.shapes3d.Bosl2Solid`). Useful for telling apart the
parts of a multi-piece model or debugging a list of paths.
Args:
items: The objects to colour.
stride: Consecutive colours stride this many steps around the wheel.
maxhues: Cap the number of distinct hues (default: ``len(items)``).
shuffle: Shuffle the hue order before colouring.
seed: Seed for the shuffle operation.
Returns:
A list of coloured objects, one per element of *items*, each
preserving its original 2-D or 3-D type.
"""
items = list(items)
colors = rainbow_colors(len(items), stride=stride, maxhues=maxhues, shuffle=shuffle, seed=seed)
return [obj.color(col) for obj, col in zip(items, colors, strict=False)]
# ---------------------------------------------------------------------------
# Colorable mixin
# ---------------------------------------------------------------------------
[docs]
class Color:
"""A normalised RGBA colour from any input format.
Accepts a CSS colour name (``"red"``), a ``#rrggbb`` hex string, an
``[R, G, B]`` list or tuple (0-1 floats or 0-255 ints), or an
``[R, G, B, A]`` list/tuple. All components are stored as 0-1 floats.
``Color("red").rgb`` → ``(1.0, 0.0, 0.0)``
"""
__slots__ = ("_r", "_g", "_b", "_a")
def __init__(self, spec: str | Sequence[float] | Sequence[int] | None = None) -> None:
"""Normalise a colour from any input format.
Args:
spec: A CSS colour name, ``#rrggbb`` hex string, ``[R,G,B]`` or
``[R,G,B,A]`` sequence (0-1 floats or 0-255 ints), or ``None``.
"""
self._a: float = 1.0
if spec is None:
self._r = self._g = self._b = 0.0
return
if isinstance(spec, str):
s = spec.strip().lower()
if s.startswith("#"):
hex_val = s.lstrip("#")
if len(hex_val) == 3:
hex_val = "".join(c * 2 for c in hex_val)
if len(hex_val) not in (6, 8):
raise Bosl2ValueError(f"invalid hex colour: {spec!r}")
try:
r, g, b = (int(hex_val[i : i + 2], 16) for i in (0, 2, 4))
self._r, self._g, self._b = r / 255, g / 255, b / 255
if len(hex_val) == 8:
self._a = int(hex_val[6:8], 16) / 255
except ValueError:
raise Bosl2ValueError(f"invalid hex colour: {spec!r}") from None
return
# only a CSS colour NAME needs the lookup table, so the dependency is imported
# here rather than at module scope (SPEC A-4: importing pybosl2 stays cheap, and
# works in runtimes that do not ship webcolors)
try:
import webcolors
except ImportError: # pragma: no cover - depends on the runtime
raise Bosl2ValueError(
f"cannot resolve the colour name {spec!r}: the webcolors package is not "
f"available in this runtime. Use a hex string ('#ff0000') or an [r, g, b] "
f"sequence instead."
) from None
try:
c = webcolors.name_to_rgb(s)
self._r, self._g, self._b = c.red / 255, c.green / 255, c.blue / 255
return
except ValueError:
raise Bosl2ValueError(f"unknown colour name: {spec!r}") from None
arr = list(spec)
n = len(arr)
if n < 3:
raise Bosl2ValueError(f"colour sequence needs at least 3 values, got {n}")
# Detect int (0-255) vs float (0-1): if any value > 1, treat as 0-255
scale = (
1.0 / 255 if any(isinstance(v, int) and v > 1 or isinstance(v, float) and v > 1 for v in arr[:3]) else 1.0
)
self._r = float(arr[0]) * scale
self._g = float(arr[1]) * scale
self._b = float(arr[2]) * scale
if n >= 4:
self._a = float(arr[3]) * (1.0 / 255 if scale < 1 else 1.0)
@property
def rgb(self) -> tuple[float, float, float]:
"""The (R, G, B) components as 0-1 floats."""
return (self._r, self._g, self._b)
@property
def rgba(self) -> tuple[float, float, float, float]:
"""The (R, G, B, A) components as 0-1 floats."""
return (self._r, self._g, self._b, self._a)
@property
def alpha(self) -> float:
"""Alpha opacity, 0-1."""
return self._a
@property
def hex(self) -> str:
"""The ``#rrggbb`` hex string."""
return f"#{int(round(self._r * 255)):02x}{int(round(self._g * 255)):02x}{int(round(self._b * 255)):02x}"
def _to_native(self) -> str | list[float]:
"""Return a value suitable for passing to PythonSCAD's ``color()``."""
if self._a >= 1.0:
return [self._r, self._g, self._b]
return [self._r, self._g, self._b, self._a]
def __str__(self) -> str:
"""Return the hex string."""
return self.hex
def __repr__(self) -> str:
"""Return a debug representation."""
return f"Color(r={self._r:.3f}, g={self._g:.3f}, b={self._b:.3f}, a={self._a:.3f})"
def __eq__(self, other: object) -> bool:
"""Return whether two colours are equal."""
if isinstance(other, Color):
return self.rgba == other.rgba
if isinstance(other, (str, list, tuple)):
try:
return self == Color(other)
except ValueError:
return False
return NotImplemented
def __hash__(self) -> int:
"""Return a hash of the colour."""
return hash(self.rgba)
# ---------------------------------------------------------------------------
# Colorable mixin
# ---------------------------------------------------------------------------
def _to_native_colour(c: Any) -> Any:
"""Convert a :class:`Color` to the ``[R, G, B]`` list the native ``color()`` builtin accepts.
A :class:`Color` means nothing to the backend -- it raises
``TypeError: Unknown color representation`` -- so it has to become its ``[R, G, B]``
list first. :meth:`ColorMixin.color` always did this; :meth:`ColorMixin.recolor` and
:meth:`ColorMixin.color_this` did not, so ``cuboid(...).recolor(Color("green"))``
failed on a value ``.color(Color("green"))`` accepted. One helper now, used by all three.
"""
if isinstance(c, Color):
return c._to_native()
return c
[docs]
class Colorable(ABC):
"""Mixin adding the color.scad colour operators as methods.
Inherited by :class:`~pybosl2.shapes3d.Bosl2Solid`. Every operator resolves
to the host's native colour primitives, which the host provides as
``_color_native`` (PythonSCAD ``color()``), ``_highlight_native`` (the ``#``
modifier) and ``_ghost_native`` (the ``%`` modifier).
**Opacity** is applied by chaining :meth:`ghost` rather than through an
``alpha`` parameter — this matches how OpenSCAD's ``%`` background modifier
works. For example ``box.hsl(0, 1, 0.5).ghost()`` produces a transparent
red box.
"""
@abstractmethod
def _color_native(self, c: Any = None, alpha: float | None = None) -> Self: # pragma: no cover
raise NotImplementedError
@abstractmethod
def _highlight_native(self) -> Self: # pragma: no cover
raise NotImplementedError
@abstractmethod
def _ghost_native(self) -> Self: # pragma: no cover
raise NotImplementedError
[docs]
def color(self, c: "Color | str | Sequence[float] | None" = None, alpha: float | None = None) -> Self:
"""Colour this object.
Args:
c: A :class:`Color` object, or ``None`` to leave colour unchanged.
alpha: Optional alpha transparency 0..1.
Returns:
This object with the colour applied, or ``self`` unchanged when both
*c* and *alpha* are ``None``.
"""
if c is None and alpha is None:
return self
return self._color_native(_to_native_colour(c), alpha)
[docs]
def recolor(self, c: Any = "default", alpha: float | None = None) -> Self:
"""Set the colour of this object and its uncoloured descendants.
In the native backend there is no ``$color`` attachment tree to revert
to, so ``"default"`` / ``None`` leaves the colour unchanged.
Args:
c: A colour name, ``[R, G, B]`` list, or ``"default"``/``None`` to skip.
alpha: Optional alpha transparency 0..1.
Returns:
This object recoloured, or ``self`` unchanged when *c* is ``"default"``
or ``None``.
"""
if c is None or c == "default":
return self
return self._color_native(_to_native_colour(c), alpha)
[docs]
def color_this(self, c: Any = "default", alpha: float | None = None) -> Self:
"""Colour just this object, without tinting its descendants.
Equivalent to :meth:`color` in the native backend, where there is no
``$color`` attachment tree to preserve separately.
Args:
c: A colour name, ``[R, G, B]`` list, or ``"default"``/``None`` to skip.
alpha: Optional alpha transparency 0..1.
Returns:
This object coloured, or ``self`` unchanged when *c* is ``"default"``
or ``None``.
"""
if c is None or c == "default":
return self
return self._color_native(_to_native_colour(c), alpha)
[docs]
def hsl(self, height: float, s: float = 1.0, length: float = 0.5, a: float | None = None) -> Self:
"""Colour this object from an HSL hue/saturation/lightness.
Uses :func:`colorsys.hls_to_rgb` for the conversion. For opacity, chain
:meth:`ghost` after this call or pass the alpha parameter *a*.
Args:
height: Hue in degrees (0=red, 120=green, 240=blue).
s: Saturation 0..1 (0 = grey, 1 = vivid).
length: Lightness 0..1 (0 = black, 0.5 = bright, 1 = white).
a: Optional alpha (opacity) 0..1.
Returns:
This object coloured with the computed ``[R, G, B]`` value.
"""
rgb = list(colorsys.hls_to_rgb(height / 360, length, s))
return self._color_native(rgb, a)
[docs]
def hsv(self, height: float, s: float = 1.0, v: float = 1.0, a: float | None = None) -> Self:
"""Colour this object from an HSV hue/saturation/value.
Uses :func:`colorsys.hsv_to_rgb` for the conversion. For opacity, chain
:meth:`ghost` after this call or pass the alpha parameter *a*.
Args:
height: Hue in degrees (0=red, 120=green, 240=blue).
s: Saturation 0..1 (0 = grey, 1 = vivid).
v: Value 0..1 (0 = black, 1 = bright).
a: Optional alpha (opacity) 0..1.
Returns:
This object coloured with the computed ``[R, G, B]`` value.
"""
rgb = list(colorsys.hsv_to_rgb(height / 360, s, v))
return self._color_native(rgb, a)
[docs]
def highlight(self, highlight: bool = True) -> Self:
"""Apply the ``#`` debug modifier.
Args:
highlight: If True (default), apply the highlight modifier.
If False, return *self* unchanged.
Returns:
This object with the highlight modifier applied, or *self* unchanged
when *highlight* is ``False``.
"""
return self._highlight_native() if highlight else self
[docs]
def ghost(self, ghost: bool = True) -> Self:
"""Apply the ``%`` (transparent, non-interacting) debug modifier.
Args:
ghost: If True (default), apply the ghost modifier.
If False, return *self* unchanged.
Returns:
This object with the ghost modifier applied, or *self* unchanged
when *ghost* is ``False``.
"""
return self._ghost_native() if ghost else self