# 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: bosl2/color.py
# Pure-Python port of BOSL2's color.scad: the HSL/HSV -> RGB colorspace conversions and the
# rainbow() helper for colouring a list of objects. The colour-application operators
# (color/recolor/color_this/hsl/hsv/highlight/ghost) live on :class:`~bosl2.shapes3d.Bosl2Solid`
# via the :class:`Colorable` mixin defined here; each resolves to the native PythonSCAD
# ``color()`` / ``highlight()`` (# modifier) / ``background()`` (% modifier) calls.
#
# Only pure math is imported at load time, so shapes3d.py can pull in the mixin during its own
# import without a cycle.
#
# FileSummary: HSL/HSV colour conversion, rainbow(), and the Colorable colour operators.
# FileGroup: BOSL2
from __future__ import annotations
import random
from abc import ABC, abstractmethod
from collections.abc import Sequence
__all__ = ["hsl", "hsv", "rainbow", "rainbow_colors", "Colorable"]
# ---------------------------------------------------------------------------
# Section: colorspace conversion
# ---------------------------------------------------------------------------
[docs]
def hsl(height: float, s: float = 1.0, length: float = 0.5, a: float | None = None) -> list[float]:
"""Convert HSL to an ``[R, G, B]`` colour (or ``[R, G, B, A]`` if *a* is given) -- BOSL2 hsl().
Args:
height: hue in degrees (0=red, 60=yellow, 120=green, 180=cyan, 240=blue, 300=magenta)
s: saturation 0..1 (0 = grey, 1 = vivid). Default 1
length: lightness 0..1 (0 = black, 0.5 = bright, 1 = white). Default 0.5
a: optional alpha 0..1; when given the result is ``[R, G, B, A]``
Returns:
``[R, G, B]`` (each 0..1), or ``[R, G, B, A]`` when *a* is given.
"""
hm = height % 360
rgb = []
for n in (0, 8, 4):
k = (n + hm / 30) % 12
rgb.append(length - s * min(length, 1 - length) * max(min(k - 3, 9 - k, 1), -1))
return rgb + ([a] if a is not None else [])
[docs]
def hsv(height: float, s: float = 1.0, v: float = 1.0, a: float | None = None) -> list[float]:
"""Convert HSV to an ``[R, G, B]`` colour (or ``[R, G, B, A]`` if *a* is given) -- BOSL2 hsv().
Args:
height: hue in degrees (0=red, 60=yellow, 120=green, 180=cyan, 240=blue, 300=magenta)
s: saturation 0..1 (0 = grey, 1 = vivid). Default 1
v: value 0..1 (0 = black, 1 = bright). Default 1
a: optional alpha 0..1; when given the result is ``[R, G, B, A]``
Returns:
``[R, G, B]`` (each 0..1), or ``[R, G, B, A]`` when *a* is given.
"""
assert 0 <= s <= 1, "hsv(): saturation must be in 0..1."
assert 0 <= v <= 1, "hsv(): value must be in 0..1."
assert a is None or 0 <= a <= 1, "hsv(): alpha must be in 0..1."
hm = height % 360
c = v * s
hp = hm / 60
x = c * (1 - abs(hp % 2 - 1))
if hp <= 1:
rp = [c, x, 0.0]
elif hp <= 2:
rp = [x, c, 0.0]
elif hp <= 3:
rp = [0.0, c, x]
elif hp <= 4:
rp = [0.0, x, c]
elif hp <= 5:
rp = [x, 0.0, c]
elif hp <= 6:
rp = [c, 0.0, x]
else:
rp = [0.0, 0.0, 0.0]
m = v - c
rgb = [rp[0] + m, rp[1] + m, rp[2] + m]
return rgb + ([a] if a is not None else [])
# ---------------------------------------------------------------------------
# Section: rainbow
# ---------------------------------------------------------------------------
[docs]
def rainbow_colors(
sides: int,
stride: int = 1,
maxhues: int | None = None,
shuffle: bool = False,
seed=None,
) -> list[list[float]]:
"""The list of ``sides`` ``[R, G, B]`` colours stepped around the ROYGBIV wheel (BOSL2 rainbow()).
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
seed: seed for the shuffle
"""
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 [hsv(height=hue) for hue in hues]
[docs]
def rainbow(
items: Sequence,
stride: int = 1,
maxhues: int | None = None,
shuffle: bool = False,
seed=None,
) -> list:
"""Colour each object in *items* a different hue, returning the coloured list (BOSL2 rainbow()).
Each item must support ``.color([r, g, b])`` (a :class:`~bosl2.shapes3d.Bosl2Solid` or a native
solid). 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
seed: seed for the shuffle
"""
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)]
# ---------------------------------------------------------------------------
# Section: Colorable mixin
# ---------------------------------------------------------------------------
[docs]
class Colorable(ABC):
"""Mixin adding the color.scad colour operators as methods.
Inherited by :class:`~bosl2.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). Because
the toolkit builds native geometry rather than a BOSL2 ``$color`` attachment tree,
:meth:`recolor` and :meth:`color_this` both apply the colour directly (an object's
already-coloured children keep their colour, matching OpenSCAD's ``color()`` semantics).
"""
@abstractmethod
def _color_native(self, c=None, alpha=None): # pragma: no cover - overridden by the host class
raise NotImplementedError
@abstractmethod
def _highlight_native(self): # pragma: no cover - overridden by the host class
raise NotImplementedError
@abstractmethod
def _ghost_native(self): # pragma: no cover - overridden by the host class
raise NotImplementedError
[docs]
def color(self, c=None, alpha: float | None = None):
"""Colour this object. *c* is a name (``"red"``), ``[R, G, B]``, or ``[R, G, B, A]``."""
if c is None and alpha is None:
return self
return self._color_native(c, alpha)
[docs]
def recolor(self, c="default", alpha: float | None = None):
"""Set the colour of this object and its uncoloured descendants (BOSL2 recolor()).
``"default"`` / ``None`` leaves the colour unchanged (there is no ``$color`` scheme to
revert to in the native backend)."""
if c is None or c == "default":
return self
return self._color_native(c, alpha)
[docs]
def color_this(self, c="default", alpha: float | None = None):
"""Colour just this object (BOSL2 color_this()); equivalent to :meth:`color` in the native
backend, where there is no ``$color`` attachment tree to preserve separately."""
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):
"""Colour this object from an HSL hue/saturation/lightness (BOSL2 hsl())."""
return self._color_native(hsl(height, s, length), a)
[docs]
def hsv(self, height: float, s: float = 1.0, v: float = 1.0, a: float | None = None):
"""Colour this object from an HSV hue/saturation/value (BOSL2 hsv())."""
return self._color_native(hsv(height, s, v), a)
[docs]
def highlight(self, highlight: bool = True):
"""Apply the ``#`` debug modifier (BOSL2 highlight()); ``False`` leaves it unmodified."""
return self._highlight_native() if highlight else self
[docs]
def ghost(self, ghost: bool = True):
"""Apply the ``%`` (transparent, non-interacting) modifier (BOSL2 ghost()); ``False`` leaves
it unmodified."""
return self._ghost_native() if ghost else self