Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ title: Changelog
## v0.17.0 - UNRELEASED

### Enhancements
- `HexColor` type added to `InputColor` — hex strings (`'#RRGGBB'` or `'#RRGGBBAA'`) can now be passed wherever a color input is accepted. Values are automatically converted from sRGB to linear color space before being applied to the socket.

```py
from nodebpy.types import HexColor

mix = g.Mix.color(a_color=HexColor("#FF8000")) # type-safe
mix = g.Mix.color(a_color="#FF8000") # also works at runtime
mix = g.Mix.color(a_color="#FF8000CC") # with explicit alpha
```

- New methods on `VectorSocket` for applying transforms:
- `rotate(rotation)` — apply a `RotationSocket` via `RotateVector`, returns `VectorSocket`
- `transform(matrix)` — apply a `MatrixSocket` via `TransformPoint`, returns `VectorSocket`
Expand Down
22 changes: 22 additions & 0 deletions src/nodebpy/builder/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@
from .mixins import LinkingMixin, OperatorMixin
from .tree import TreeBuilder


def _srgb_to_linear(c: float) -> float:
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4


def _linear_to_srgb(c: float) -> float:
if c <= 0.0031308:
return c * 12.92
return 1.055 * c ** (1.0 / 2.4) - 0.055


def _hex_to_linear_rgba(hex_str: str) -> tuple[float, float, float, float]:
h = hex_str.lstrip("#")
r, g, b = (v / 255 for v in bytes.fromhex(h[:6]))
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1.0
return (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b), a)


_T = TypeVar("_T", bound=bpy.types.NodeTree)

if TYPE_CHECKING:
Expand Down Expand Up @@ -133,6 +153,8 @@ def _find_or_create_linked(cls, socket: NodeSocket) -> Self:
def _set_input_default_value(self, input: NodeSocket, value: Any) -> None:
"""Set the default value for an input socket, handling type conversions."""
assert hasattr(input, "default_value")
if isinstance(value, str) and hasattr(input, "type") and input.type == "RGBA":
value = _hex_to_linear_rgba(value)
if (
hasattr(input, "type")
and input.type == "VECTOR"
Expand Down
18 changes: 18 additions & 0 deletions src/nodebpy/types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
import typing
from types import EllipsisType
from typing import Literal
Expand Down Expand Up @@ -58,6 +59,22 @@
from .builder import Socket as SocketLinker


_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$")


class HexColor(str):
"""Hex color string in sRGB: ``'#RRGGBB'`` or ``'#RRGGBBAA'``."""

__slots__ = ()

def __new__(cls, value: str) -> "HexColor":
if not _HEX_RE.match(value):
raise ValueError(
f"Invalid hex color {value!r}: expected '#RRGGBB' or '#RRGGBBAA'"
)
return super().__new__(cls, value)


def _is_default_value(value: InputAny):
return isinstance(value, (int, float, str, bool, tuple, list, Euler))

Expand Down Expand Up @@ -93,6 +110,7 @@ def _is_default_value(value: InputAny):
]
InputColor = typing.Union[
tuple[float, float, float, float],
HexColor,
NodeSocketColor,
NodeSocketVector,
float,
Expand Down
117 changes: 116 additions & 1 deletion tests/test_node_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1017,4 +1017,119 @@ def test_rshift_falls_back_to_target_find_best_socket_pair(self):
result = cube >> join

assert result.node.bl_idname == "GeometryNodeJoinGeometry"
assert len(result.node.inputs[0].links) == 1


class TestHexColorInput:
"""Tests for HexColor validation and hex-to-linear conversion."""

# -- HexColor constructor --

def test_valid_6digit(self):
from nodebpy.types import HexColor

assert str(HexColor("#FF0000")) == "#FF0000"

def test_valid_8digit(self):
from nodebpy.types import HexColor

assert str(HexColor("#FF000080")) == "#FF000080"

def test_lowercase_accepted(self):
from nodebpy.types import HexColor

assert str(HexColor("#ff8000")) == "#ff8000"

def test_invalid_no_hash_raises(self):
from nodebpy.types import HexColor

with pytest.raises(ValueError, match="Invalid hex color"):
HexColor("FF0000")

def test_invalid_short_raises(self):
from nodebpy.types import HexColor

with pytest.raises(ValueError):
HexColor("#FF00")

def test_invalid_7digit_raises(self):
from nodebpy.types import HexColor

with pytest.raises(ValueError):
HexColor("#FF00001")

def test_invalid_non_hex_chars_raises(self):
from nodebpy.types import HexColor

with pytest.raises(ValueError):
HexColor("#GGHHII")

# -- _hex_to_linear_rgba conversion --

def test_white_stays_white(self):
from nodebpy.builder.node import _hex_to_linear_rgba

assert_allclose(_hex_to_linear_rgba("#FFFFFF"), (1.0, 1.0, 1.0, 1.0), atol=1e-6)

def test_black_stays_black(self):
from nodebpy.builder.node import _hex_to_linear_rgba

assert_allclose(_hex_to_linear_rgba("#000000"), (0.0, 0.0, 0.0, 1.0), atol=1e-6)

def test_pure_red_channels(self):
from nodebpy.builder.node import _hex_to_linear_rgba

r, g_val, b, a = _hex_to_linear_rgba("#FF0000")
assert_allclose(r, 1.0, atol=1e-6)
assert_allclose(g_val, 0.0, atol=1e-6)
assert_allclose(b, 0.0, atol=1e-6)
assert_allclose(a, 1.0, atol=1e-6)

def test_alpha_not_gamma_corrected(self):
from nodebpy.builder.node import _hex_to_linear_rgba

# 0x80 = 128; alpha is linear opacity, not a colour channel
_, _, _, a = _hex_to_linear_rgba("#FFFFFF80")
assert_allclose(a, 128 / 255, atol=1e-6)

def test_midgrey_is_linearised(self):
from nodebpy.builder.node import _hex_to_linear_rgba, _srgb_to_linear

r, _, _, _ = _hex_to_linear_rgba("#808080")
assert_allclose(r, _srgb_to_linear(128 / 255), atol=1e-6)
# Linear mid-grey is always darker than the sRGB encoding
assert r < 128 / 255

# -- Integration: hex string as node color input --

def test_plain_str_sets_linear_color(self):
with TreeBuilder("HexColorPlainStr"):
mix = g.Mix.color(a_color="#FF0000")
rgba = tuple(mix.i["A_Color"].socket.default_value)
assert_allclose(rgba[0], 1.0, atol=1e-6)
assert_allclose(rgba[1], 0.0, atol=1e-4)
assert_allclose(rgba[2], 0.0, atol=1e-4)
assert_allclose(rgba[3], 1.0, atol=1e-6)

def test_hex_color_type_sets_color(self):
from nodebpy.types import HexColor

with TreeBuilder("HexColorTyped"):
mix = g.Mix.color(a_color=HexColor("#FFFFFF"))
rgba = tuple(mix.i["A_Color"].socket.default_value)
assert_allclose(rgba, (1.0, 1.0, 1.0, 1.0), atol=1e-6)

def test_hex_with_explicit_alpha(self):
with TreeBuilder("HexColorAlpha"):
mix = g.Mix.color(a_color="#FF000080")
rgba = tuple(mix.i["A_Color"].socket.default_value)
assert_allclose(rgba[3], 128 / 255, atol=1e-6)

def test_midgrey_hex_converted_not_raw(self):
from nodebpy.builder.node import _srgb_to_linear

with TreeBuilder("HexColorMidGrey"):
mix = g.Mix.color(a_color="#808080")
rgba = tuple(mix.i["A_Color"].socket.default_value)
assert_allclose(rgba[0], _srgb_to_linear(128 / 255), atol=1e-6)
# Confirm it's not the raw sRGB byte value
assert rgba[0] != pytest.approx(128 / 255)
Loading