Source code for svg3d.shaders

from abc import ABC, abstractmethod

import numpy as np

from svg3d.svg3d import Mesh
from svg3d.utils import _stable_normalize

DEFAULT_LIGHT = np.array([1, 1, 0.5], dtype=float)


[docs] def hex2rgb(hexc): """ Convert a hexadecimal color string to an RGB array normalized to [0, 1]. Parameters ---------- hexc : str A hexadecimal color string, with or without a leading `#`. Returns ------- :math:`(3,)` :class:`numpy.ndarray`: A NumPy array containing RGB values normalized to the range [0, 1]. Examples -------- >>> hex2rgb("#FFFFFF") array([1., 1., 1.]) >>> hex2rgb("000000") array([0., 0., 0.]) """ hexc = hexc.lstrip("#") return np.array([int(hexc[i : i + 2], 16) for i in (0, 2, 4)]) / 255.0
[docs] def rgb2hex(rgb): """ Convert an RGB color array to a hexadecimal color string. Parameters ---------- rgb : :math:`(3,)` :class:`numpy.ndarray`: The RGB values to convert. Returns ------- str : A hexadecimal color string in uppercase format, prefixed with `#`. Examples -------- >>> rgb2hex(np.array([1.0, 1.0, 1.0])) '#FFFFFF' >>> rgb2hex(np.array([0.0, 0.0, 0.0])) '#000000' """ rgb = (rgb * 255).astype(int) return "#{:02x}{:02x}{:02x}".format(*rgb).upper()
def _apply_shading(base_color, shading, absorbance=0.5): # `shading` is a value between -1 and 1 # factor controls how much lighter/darker we go from the base color base_rgb = hex2rgb(base_color) shaded_color = base_rgb + absorbance * shading * (np.ones(3) - base_rgb) shaded_color = np.clip(shaded_color, 0, 1) # Ensure RGB values are within [0, 1] return rgb2hex(shaded_color)
[docs] def diffuse_lighting( face_index, mesh, light_direction=None, base_style=None, base_color="#71618D" ): """Apply Lambertian (dot product diffuse) shading to a face in an \ :obj:`~.Mesh`. This is a convenience function for backwards compatibility. The full-featured :obj:`~.Shader` class should be used in most instances. """ base_style = base_style if base_style is not None else {} light_direction = light_direction if light_direction is not None else DEFAULT_LIGHT normal = _stable_normalize(mesh.normals[face_index]) shading = np.dot(normal, light_direction) new_color = _apply_shading(base_color, shading, absorbance=0.6) return base_style | {"fill": new_color}
[docs] class Shader(ABC): """ Abstract base class for shaders. """
[docs] def __init__(self, base_style=None): """Initialize the shader. Parameters ---------- base_style: dict | None, optional The style attribute dict for the :obj:`~.Shader`. """ self._base_style = base_style
[docs] @abstractmethod def __call__(self, face_index: int, mesh: Mesh) -> dict: """Compute the shaded style for a face in a mesh. Abstract method to be implemented in subclasses. """ return {}
@property def base_style(self): """dict: Get or set the style attribute dict for the object.""" return self._base_style @base_style.setter def base_style(self, base_style: dict): self._base_style = base_style
[docs] class UniformShader(Shader): """Shade all faces of a :obj:`~.Mesh` with a single, uniform color. This shader is useful in figure generation and when simplicity and clarity are maximally important. """
[docs] def __init__(self, base_style=None): """Initialize the diffuse shader. Parameters ---------- base_style : dict | None, optional The style dict for the :obj:`~.Shader`. """ super().__init__(base_style=base_style)
[docs] def __call__(self, face_index: int, mesh: Mesh) -> dict: """Render face index `i` in a `mesh` based on the shader's style dict.""" base_style = self.base_style if self.base_style is not None else {} return base_style
[docs] class DiffuseShader(Shader): """ Shade :obj:`~.Mesh` objects with Lambertian (dot product diffuse) lighting. """
[docs] def __init__(self, base_style, light_direction=DEFAULT_LIGHT, absorbance=0.6): """Initialize the diffuse shader. Parameters ---------- base_color : str, optional A hexadecimal-formatted color string for the mesh. Default is "#71618D". light_direction : iterable of float, optional A 3-element array specifying the direction of the light source. Default is (1.0, 1.0, 0.5). base_style : dict | None, optional The style dict for the :obj:`~.Shader`. """ super().__init__(base_style=base_style) self.absorbance = absorbance self._diffuse_light_direction = np.asarray(light_direction)
[docs] @classmethod def from_style_dict(cls, style: dict, light_direction=DEFAULT_LIGHT): """Create a :obj:`~.Shader` instance with a style dictionary. Parameters ---------- style : dict The style dict for the :obj:`~.Shader` light_direction : array or list of float, optional. A 3-element iterable specifying the diffuse light direction. Default \ value: (1.0, 1.0, 0.5) """ return cls(base_style=style, light_direction=light_direction)
[docs] @classmethod def from_color(cls, base_color): """Create a :obj:`~.Shader` instance with a specified base color. Parameters ---------- base_color : str The base color as a hexadecimal string (e.g., `#FFFFFF`). """ return cls(base_style={"fill": base_color})
[docs] @classmethod def from_color_and_direction(cls, base_color, light_direction): """Create a :obj:`~.Shader` instance with a specified base color \ and light direction. Parameters ---------- base_color : str The base color as a hexadecimal string (e.g., `#FFFFFF`). light_direction : array or list of float A 3-element iterable specifying the diffuse light direction. """ return cls(base_style={"fill": base_color}, light_direction=light_direction)
[docs] def __call__(self, face_index: int, mesh: Mesh) -> dict: """Compute the shaded style for a face in a mesh. Parameters ---------- face_index : int Index of the face in the mesh. mesh : Mesh An svg3d mesh object. absorbance : float, optional The "absorbance" of the mesh surface. Should fall in the range [0.0, 1.0), \ with larger values equating to darker shading. Default is 0.6. Returns ------- dict A dictionary containing the SVG style attributes for the shaded face. """ base_style = self.base_style if self.base_style is not None else {} normal = _stable_normalize(mesh.normals[face_index]) shading = np.dot(normal, self.diffuse_light_direction) new_color = self._apply_shading( self.base_style.get("fill", "#FFFFFF"), shading, absorbance=self.absorbance ) return {**base_style, "fill": new_color}
def _apply_shading(self, base_color, shading, absorbance=0.5): """Apply shading model to an input color.""" base_rgb = hex2rgb(base_color) shaded_color = base_rgb + absorbance * shading * (np.ones(3) - base_rgb) shaded_color = np.clip(shaded_color, 0, 1) return rgb2hex(shaded_color) @property def diffuse_light_direction(self): """ np.ndarray: A 3-element array representing the direction of the light source. """ return self._diffuse_light_direction @diffuse_light_direction.setter def diffuse_light_direction(self, light_direction): """ Set the direction of the diffuse light source. Parameters ---------- light_direction : array or list of float A 3-element iterable specifying the diffuse light direction. Raises ------ AssertionError If light_direction is not an iterable of length three. """ msg = "Light direction should be an iterable with length three." assert hasattr(light_direction, "__len__") and len(light_direction) == 3, msg self._diffuse_light_direction = np.asarray(light_direction) @property def base_color(self): """dict: Get or set the base color for the mesh from a hexadecimal string.""" return self._base_color @base_color.setter def base_color(self, base_color): self._base_color = base_color