Skip to content

Commit 77fb3ad

Browse files
authored
feat: Background colors (#53)
* feat: Set background colors * Remove unused methods in view adaptors
1 parent d821224 commit 77fb3ad

5 files changed

Lines changed: 129 additions & 54 deletions

File tree

src/scenex/adaptors/_pygfx/_view.py

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,17 @@ def __init__(self, view: model.View, **backend_kwargs: Any) -> None:
3434

3535
self._snx_set_scene(view.scene)
3636
self._snx_set_camera(view.camera)
37-
# TODO: this is needed... but breaks tests until we deal with Layout better.
38-
# self._snx_set_background_color(view.layout.background_color)
37+
38+
# -- Layout components -- #
39+
self._background_mat = pygfx.BackgroundMaterial()
40+
self._background = pygfx.Background(None, material=self._background_mat)
41+
self._pygfx_scene.add(self._background)
42+
43+
# -- Layout connections -- #
44+
self._model.layout.events.background_color.connect(self._set_background_color)
45+
46+
# -- Layout initialization -- #
47+
self._set_background_color(view.layout.background_color)
3948

4049
def _snx_set_visible(self, arg: bool) -> None:
4150
pass
@@ -67,28 +76,9 @@ def _draw(self, renderer: pygfx.renderers.WgpuRenderer) -> None:
6776

6877
renderer.render(self._pygfx_scene, self._pygfx_cam, rect=rect, flush=False)
6978

70-
def _snx_set_position(self, arg: tuple[float, float]) -> None:
71-
logger.warning("View.set_position not implemented for pygfx")
72-
73-
def _snx_set_size(self, arg: tuple[float, float] | None) -> None:
74-
logger.warning("Ignoring View.set_size(None): Don't know how to handle this...")
75-
76-
def _snx_set_background_color(self, color: Color | None) -> None:
77-
colors = (color.rgba,) if color is not None else ()
78-
background = pygfx.Background(None, material=pygfx.BackgroundMaterial(*colors))
79-
self._pygfx_scene.add(background)
80-
81-
def _snx_set_border_width(self, arg: float) -> None:
82-
logger.warning("View.set_border_width not implemented for pygfx")
83-
84-
def _snx_set_border_color(self, arg: Color | None) -> None:
85-
logger.warning("View.set_border_color not implemented for pygfx")
86-
87-
def _snx_set_padding(self, arg: int) -> None:
88-
logger.warning("View.set_padding not implemented for pygfx")
89-
90-
def _snx_set_margin(self, arg: int) -> None:
91-
logger.warning("View.set_margin not implemented for pygfx")
79+
def _set_background_color(self, color: Color | None) -> None:
80+
rgba = color.rgba if color is not None else (0, 0, 0, 0)
81+
self._background_mat.set_colors(rgba)
9282

9383
def _snx_render(self) -> np.ndarray:
9484
"""Render to offscreen buffer."""

src/scenex/adaptors/_vispy/_view.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import warnings
43
from typing import TYPE_CHECKING, Any, cast
54

65
import numpy as np
@@ -40,7 +39,12 @@ def __init__(self, view: model.View, **backend_kwargs: Any) -> None:
4039
self._snx_set_camera(view.camera)
4140
self._snx_set_scene(view.scene)
4241

42+
# -- Layout connections -- #
43+
self._model.layout.events.background_color.connect(self._set_background_color)
4344
view.layout.events.all.connect(self._on_layout_changed)
45+
46+
# -- Layout initialization -- #
47+
self._set_background_color(view.layout.background_color)
4448
self._on_layout_changed()
4549

4650
def _on_vispy_viewbox_resized(self, event: Any) -> None:
@@ -54,9 +58,6 @@ def _on_layout_changed(self, event: Any | None = None) -> None:
5458
self._vispy_viewbox.update()
5559
self._cam_adaptor._set_view(rect.width, rect.height)
5660

57-
def _snx_get_native(self) -> Any:
58-
return self._vispy_viewbox
59-
6061
def _snx_set_visible(self, arg: bool) -> None:
6162
pass
6263

@@ -96,31 +97,9 @@ def _snx_set_camera(self, cam: model.Camera) -> None:
9697
def _draw(self) -> None:
9798
self._vispy_viewbox.update()
9899

99-
def _snx_set_position(self, arg: tuple[float, float]) -> None:
100-
raise NotImplementedError()
101-
102-
def _snx_set_size(self, arg: tuple[float, float] | None) -> None:
103-
raise NotImplementedError()
104-
105-
def _snx_set_border_width(self, arg: float) -> None:
106-
warnings.warn(
107-
"set_border_width not implemented for vispy", RuntimeWarning, stacklevel=2
108-
)
109-
110-
def _snx_set_border_color(self, arg: Color | None) -> None:
111-
warnings.warn(
112-
"set_border_color not implemented for vispy", RuntimeWarning, stacklevel=2
113-
)
114-
115-
def _snx_set_padding(self, arg: int) -> None:
116-
warnings.warn(
117-
"set_padding not implemented for vispy", RuntimeWarning, stacklevel=2
118-
)
119-
120-
def _snx_set_margin(self, arg: int) -> None:
121-
warnings.warn(
122-
"set_margin not implemented for vispy", RuntimeWarning, stacklevel=2
123-
)
100+
def _set_background_color(self, color: Color | None) -> None:
101+
color_data = None if color is None else color.rgba
102+
self._vispy_viewbox.bgcolor = color_data
124103

125104
def _snx_render(self) -> np.ndarray:
126105
"""Render to screenshot."""
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import pytest
5+
from cmap import Color
6+
7+
import scenex as snx
8+
import scenex.adaptors._pygfx as adaptors
9+
from scenex.adaptors import get_adaptor_registry
10+
11+
12+
@pytest.fixture
13+
def view() -> snx.View:
14+
return snx.View()
15+
16+
17+
@pytest.fixture
18+
def adaptor(view: snx.View) -> adaptors.View:
19+
adaptor = get_adaptor_registry().get_adaptor(view, create=True)
20+
assert isinstance(adaptor, adaptors.View)
21+
return adaptor
22+
23+
24+
def test_background_color(view: snx.View, adaptor: adaptors.View) -> None:
25+
# Default background color from Layout defaults (black)
26+
np.testing.assert_array_almost_equal(
27+
adaptor._background_mat.color_bottom_left.rgba,
28+
Color("black").rgba,
29+
)
30+
31+
# Changing the model color propagates to the native background material
32+
view.layout.background_color = Color("red")
33+
np.testing.assert_array_almost_equal(
34+
adaptor._background_mat.color_bottom_left.rgba,
35+
Color("red").rgba,
36+
)
37+
38+
# Setting to None makes the background transparent
39+
view.layout.background_color = None
40+
np.testing.assert_array_almost_equal(
41+
adaptor._background_mat.color_bottom_left.rgba,
42+
np.zeros(4),
43+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import pytest
5+
from cmap import Color
6+
7+
import scenex as snx
8+
import scenex.adaptors._vispy as adaptors
9+
from scenex.adaptors import get_adaptor_registry
10+
11+
12+
@pytest.fixture
13+
def view() -> snx.View:
14+
return snx.View()
15+
16+
17+
@pytest.fixture
18+
def adaptor(view: snx.View) -> adaptors.View:
19+
adaptor = get_adaptor_registry().get_adaptor(view, create=True)
20+
assert isinstance(adaptor, adaptors.View)
21+
return adaptor
22+
23+
24+
def test_background_color(view: snx.View, adaptor: adaptors.View) -> None:
25+
# Default background color from Layout defaults (black)
26+
np.testing.assert_array_almost_equal(
27+
adaptor._vispy_viewbox.bgcolor.rgba.ravel(),
28+
Color("black").rgba,
29+
)
30+
31+
# Changing the model color propagates to the native viewbox bgcolor
32+
view.layout.background_color = Color("red")
33+
np.testing.assert_array_almost_equal(
34+
adaptor._vispy_viewbox.bgcolor.rgba.ravel(),
35+
Color("red").rgba,
36+
)
37+
38+
# Setting to None sets the viewbox background to all zeros
39+
view.layout.background_color = None
40+
np.testing.assert_array_almost_equal(
41+
adaptor._vispy_viewbox.bgcolor.rgba.ravel(),
42+
np.zeros(4),
43+
)

tests/test_basic_scene.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
""".strip()
2222

2323

24+
_BACKEND_INTERNAL = frozenset({"Background"})
25+
26+
2427
def _obj_name(obj: Any) -> str:
2528
"""Return the name of a backend node object.
2629
Replace backend names with the corresponding model name.
@@ -32,9 +35,26 @@ def _obj_name(obj: Any) -> str:
3235
return "Points"
3336
if name == "SubScene":
3437
return "Scene"
38+
if name in _BACKEND_INTERNAL:
39+
return "<BackendInternal>"
3540
return name
3641

3742

43+
def _filter_backend_nodes(tree: Any) -> Any:
44+
"""Recursively remove backend-internal nodes from a tree_dict result.
45+
46+
Some backends (e.g. pygfx) automatically add nodes like Background to
47+
represent layout properties (background color). These have no counterpart
48+
in the scenex model tree, so we filter them out for structural comparisons.
49+
"""
50+
if isinstance(tree, str):
51+
return tree
52+
return {
53+
k: [_filter_backend_nodes(c) for c in v if not (c == "<BackendInternal>")]
54+
for k, v in tree.items()
55+
}
56+
57+
3858
def _child_names(obj: Any) -> list[str]:
3959
"""Get the names of the children of a obj."""
4060
return [_obj_name(child) for child in obj.children]
@@ -72,7 +92,7 @@ def test_view_tree_matches_native(basic_view: snx.View) -> None:
7292
native_scene = _native_scene(basic_view.scene)
7393
view_tree = snx.util.tree_dict(native_scene, obj_name=_obj_name)
7494
assert isinstance(view_tree, dict)
75-
assert model_tree == view_tree
95+
assert model_tree == _filter_backend_nodes(view_tree)
7696

7797

7898
def test_changing_parent_updates_adaptor() -> None:

0 commit comments

Comments
 (0)