Source code for bosl2.color

# 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