From 261588b104218f6c8759d98ab795f2619e383e56 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 14 Aug 2026 16:23:48 +0200 Subject: [PATCH 1/2] fix: inherit color from source artist --- src/plopp/backends/pythreejs/scatter3d.py | 5 +++++ src/plopp/widgets/clip3d.py | 5 +++++ tests/widgets/clip3d_test.py | 26 +++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/plopp/backends/pythreejs/scatter3d.py b/src/plopp/backends/pythreejs/scatter3d.py index 20050b91..a905ec34 100644 --- a/src/plopp/backends/pythreejs/scatter3d.py +++ b/src/plopp/backends/pythreejs/scatter3d.py @@ -212,6 +212,11 @@ def color(self) -> np.ndarray: def color(self, val: np.ndarray): self.geometry.attributes['color'].array = val + def _copy_color_from(self, other: 'Scatter3d') -> None: + self._color = other._color + self._artist_number = other._artist_number + self.color = self._make_colors() + @property def geometry(self) -> p3.BufferGeometry: """ diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index 4c57dae2..46fa267e 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -509,6 +509,11 @@ def update_controls(self): self._nodes.clear() self.update_state() + if self._view.colormapper is None: + for source_id, node in self._nodes.items(): + self._view.artists[node.id]._copy_color_from( + self._view.artists[source_id] + ) def _set_opacity(self, change: dict[str, Any]): """ diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 8dfb864f..991fa48c 100644 --- a/tests/widgets/clip3d_test.py +++ b/tests/widgets/clip3d_test.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) +import numpy as np import pytest import scipp as sc @@ -9,6 +10,31 @@ from plopp.widgets import ClippingManager +def test_cut_colors_match_original_artists(): + a = scatter(seed=1) + b = scatter(seed=2) + b.coords['x'] += sc.scalar(60, unit='m') + nodes = [Node(a), Node(b)] + fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=False) + clip = ClippingManager(fig) + + clip.add_y_cut.click() + + for source in nodes: + cut = clip._nodes[source.id] + assert np.array_equal( + fig.artists[cut.id].color[0], fig.artists[source.id].color[0] + ) + + ycut = clip.cuts[-1] + ycut.slider.value = [ycut.slider.min, ycut.slider.value[1]] + for source in nodes: + cut = clip._nodes[source.id] + assert np.array_equal( + fig.artists[cut.id].color[0], fig.artists[source.id].color[0] + ) + + @pytest.mark.parametrize('multiple_nodes', [False, True]) def test_add_remove_cuts(multiple_nodes): a = scatter() From 0c2d14631e8e0eff3fafe288bb2ff34c79f08b35 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 14 Aug 2026 16:40:21 +0200 Subject: [PATCH 2/2] fix --- src/plopp/backends/pythreejs/scatter3d.py | 27 +++++++------------ .../pythreejs/pythreejs_scatter3d_test.py | 12 +++++++++ tests/widgets/clip3d_test.py | 12 +++++++-- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/plopp/backends/pythreejs/scatter3d.py b/src/plopp/backends/pythreejs/scatter3d.py index a905ec34..4337177b 100644 --- a/src/plopp/backends/pythreejs/scatter3d.py +++ b/src/plopp/backends/pythreejs/scatter3d.py @@ -2,7 +2,7 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) import uuid -from typing import Literal +from typing import Literal, cast import numpy as np import pythreejs as p3 @@ -75,8 +75,12 @@ def __init__( self._x = x self._y = y self._z = z - self._color = color - self._artist_number = artist_number + self._color = None + if colormapper is None: + self._color = np.array( + to_rgb(f'C{artist_number}' if color is None else color), + dtype='float32', + ) # TODO: remove pixel_size in the next release self._size = size if pixel_size is None else pixel_size @@ -124,18 +128,8 @@ def _make_positions(self) -> np.ndarray: def _make_colors(self) -> np.ndarray: if self._colormapper is not None: return self._colormapper.rgba(self._data)[..., :3].astype('float32') - else: - return np.broadcast_to( - np.array( - to_rgb( - f'C{self._artist_number}' - if self._color is None - else self._color - ), - dtype='float32', - ), - (self._data.coords[self._x].shape[0], 3), - ) + color = cast(np.ndarray, self._color) + return np.broadcast_to(color, (self._data.coords[self._x].shape[0], 3)) def _make_geometry( self, positions: np.ndarray, colors: np.ndarray @@ -213,8 +207,7 @@ def color(self, val: np.ndarray): self.geometry.attributes['color'].array = val def _copy_color_from(self, other: 'Scatter3d') -> None: - self._color = other._color - self._artist_number = other._artist_number + self._color = other.color[0].copy() self.color = self._make_colors() @property diff --git a/tests/backends/pythreejs/pythreejs_scatter3d_test.py b/tests/backends/pythreejs/pythreejs_scatter3d_test.py index 257c4975..26b19e82 100644 --- a/tests/backends/pythreejs/pythreejs_scatter3d_test.py +++ b/tests/backends/pythreejs/pythreejs_scatter3d_test.py @@ -4,6 +4,7 @@ import numpy as np import pytest import scipp as sc +from matplotlib import cycler, rc_context from plopp.backends.pythreejs.canvas import Canvas from plopp.backends.pythreejs.scatter3d import Scatter3d @@ -30,6 +31,17 @@ def test_update(): assert sc.identical(scat._data, da * 2.5) +def test_update_with_different_number_of_points_preserves_default_color(): + with rc_context({'axes.prop_cycle': cycler(color=['#102030'])}): + scat = Scatter3d(canvas=Canvas(), data=scatter(20), x='x', y='y', z='z') + color = scat.color[0].copy() + + with rc_context({'axes.prop_cycle': cycler(color=['#708090'])}): + scat.update(scatter(30)) + + np.testing.assert_array_equal(scat.color, np.broadcast_to(color, (30, 3))) + + def test_bounding_box(): da = scatter() pix = 0.5 diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 991fa48c..c7930372 100644 --- a/tests/widgets/clip3d_test.py +++ b/tests/widgets/clip3d_test.py @@ -3,6 +3,7 @@ import numpy as np import pytest import scipp as sc +from matplotlib import cycler, rc_context from plopp import Node from plopp.data.testing import data_array, scatter @@ -15,10 +16,12 @@ def test_cut_colors_match_original_artists(): b = scatter(seed=2) b.coords['x'] += sc.scalar(60, unit='m') nodes = [Node(a), Node(b)] - fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=False) + with rc_context({'axes.prop_cycle': cycler(color=['#102030', '#405060'])}): + fig = scatter3dfigure(*nodes, x='x', y='y', z='z', cbar=False) clip = ClippingManager(fig) - clip.add_y_cut.click() + with rc_context({'axes.prop_cycle': cycler(color=['#708090', '#a0b0c0'])}): + clip.add_y_cut.click() for source in nodes: cut = clip._nodes[source.id] @@ -26,8 +29,13 @@ def test_cut_colors_match_original_artists(): fig.artists[cut.id].color[0], fig.artists[source.id].color[0] ) + npoints = [fig.artists[node.id].data.shape[0] for node in clip._nodes.values()] ycut = clip.cuts[-1] ycut.slider.value = [ycut.slider.min, ycut.slider.value[1]] + clip.update_state() + assert [ + fig.artists[node.id].data.shape[0] for node in clip._nodes.values() + ] != npoints for source in nodes: cut = clip._nodes[source.id] assert np.array_equal(