# 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: Colour operators (Colorable mixin) via Python's colorsys module.
# DocCategory: Foundational
# FileGroup: BOSL2
from __future__ import annotations
import colorsys
import random
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Self
from pybosl2._shape import BaseShape as Bosl2Shape
__all__ = ["rainbow", "rainbow_colors", "Colorable"]
# ---------------------------------------------------------------------------
# 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 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: Any = None, alpha: float | None = None) -> Self:
"""Colour this object.
Args:
c: A colour name (``"red"``), ``[R, G, B]`` list, or ``[R, G, B, A]`` list.
Pass ``None`` to leave the 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(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(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(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 (BOSL2 highlight()).
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