From 573c2b3c3e863789a8f4eeb117014d2de7cdffa2 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 08:41:27 +0100 Subject: [PATCH 1/5] add option to disable perspective in 3d plots --- src/plopp/backends/pythreejs/canvas.py | 16 ++++++++++++++-- src/plopp/graphics/graphicalview.py | 2 ++ src/plopp/plotting/scatter3d.py | 5 +++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index 4701d6f5..c8800b3c 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -8,6 +8,7 @@ import ipywidgets as ipw import numpy as np +import pythreejs as p3 import scipp as sc from ...graphics import Camera @@ -38,9 +39,10 @@ def __init__( figsize: tuple[int, int] | None = None, title: str | None = None, camera: Camera | None = None, + perspective: bool = True, **ignored, ): - import pythreejs as p3 + # import pythreejs as p3 self.dims = {} self.units = {} @@ -58,7 +60,8 @@ def __init__( self._title = self._make_title() width, height = self.figsize self._user_camera = Camera() if camera is None else camera - self.camera = p3.PerspectiveCamera(aspect=width / height) + # camera_maker = p3.PerspectiveCamera if perspective else p3.OrthographicCamera + # self.camera = camera_maker(aspect=width / height) self.camera_backup = {} self.axes_3d = p3.AxesHelper() self.bbox = BoundingBox() @@ -141,6 +144,7 @@ def draw(self): center = [var.mean().value for var in limits] distance_fudge_factor = 1.2 + # TODO: if orthographic camera box_size = np.array([(limits[i][1] - limits[i][0]).value for i in range(3)]) self.camera.position = self._user_camera.get( 'position', list(np.array(center) + distance_fudge_factor * box_size) @@ -254,6 +258,14 @@ def toggle_axes3d(self): """ self.axes_3d.visible = not self.axes_3d.visible + def toggle_perspective(self): + self.remove(self.camera) + self.camera = p3.OrthographicCamera( + far=self.camera.far, near=self.camera.far * 1e-5 + ) + self.add(self.camera) + self.draw() + def add(self, obj: Any): """ Add an object to the ``scene``. diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 65ca7133..c035e8f4 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -62,6 +62,7 @@ def __init__( format: Literal['svg', 'png'] | None = None, legend: bool | tuple[float, float] = True, camera: Camera | None = None, + perspective: bool = True, autoscale: bool = True, ax: Any = None, cax: Any = None, @@ -105,6 +106,7 @@ def __init__( title=title, legend=legend, camera=camera, + perspective=perspective, ax=ax, cax=cax, xmin=xmin, diff --git a/src/plopp/plotting/scatter3d.py b/src/plopp/plotting/scatter3d.py index e869b105..a089028b 100644 --- a/src/plopp/plotting/scatter3d.py +++ b/src/plopp/plotting/scatter3d.py @@ -54,6 +54,7 @@ def scatter3d( logc: bool | None = None, nan_color: str | None = None, norm: Literal['linear', 'log'] | None = None, + perspective: bool = True, title: str | None = None, vmax: sc.Variable | float = None, vmin: sc.Variable | float = None, @@ -109,6 +110,9 @@ def scatter3d( norm: Set to ``'log'`` for a logarithmic colorscale (only applicable if ``cbar`` is ``True``). Legacy, prefer ``logc`` instead. + perspective: + Set to ``True`` for a perspective camera. ``False`` will give an orthographic + (flat) camera. title: The figure title. vmin: @@ -155,6 +159,7 @@ def scatter3d( logc=logc, nan_color=nan_color, norm=norm, + perspective=perspective, title=title, vmax=vmax, vmin=vmin, From c81cc76553376c55e545356929b2db52c4c66235 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 20:52:58 +0100 Subject: [PATCH 2/5] fix camera position and range, and enable backup of positions --- src/plopp/backends/pythreejs/canvas.py | 137 +++++++++++++++++-------- 1 file changed, 95 insertions(+), 42 deletions(-) diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index c8800b3c..caade70e 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -60,8 +60,11 @@ def __init__( self._title = self._make_title() width, height = self.figsize self._user_camera = Camera() if camera is None else camera - # camera_maker = p3.PerspectiveCamera if perspective else p3.OrthographicCamera - # self.camera = camera_maker(aspect=width / height) + self._perspective = perspective + if self._perspective: + self.camera = p3.PerspectiveCamera(aspect=width / height) + else: + self.camera = p3.OrthographicCamera() self.camera_backup = {} self.axes_3d = p3.AxesHelper() self.bbox = BoundingBox() @@ -85,7 +88,9 @@ def to_widget(self): self.renderer.layout = ipw.Layout(max_width='100%', overflow='auto') return self.renderer - def set_axes(self, dims, units, dtypes): + def set_axes( + self, dims: dict[str, int], units: dict[str, str], dtypes: dict[str, Any] + ) -> None: """ Set the axes dimensions and units. @@ -102,7 +107,87 @@ def set_axes(self, dims, units, dtypes): self.dims = dims self.dtypes = dtypes - def draw(self): + def _backup_camera_settings( + self, + center: list[float], + look_at: tuple[float, float, float], + view_distance: float, + ) -> None: + # Save camera settings + self.camera_backup["reset"] = copy(self.camera.position) + self.camera_backup["look_at"] = copy(look_at) + self.camera_backup["center"] = tuple(copy(center)) + self.camera_backup["x_normal"] = [ + center[0] - view_distance, + center[1], + center[2], + ] + self.camera_backup["y_normal"] = [ + center[0], + center[1] - view_distance, + center[2], + ] + self.camera_backup["z_normal"] = [ + center[0], + center[1], + center[2] - view_distance, + ] + + def _update_perspective_camera( + self, + limits: tuple[sc.Variable, sc.Variable, sc.Variable], + center: list[float], + look_at: tuple[float, float, float], + ) -> None: + distance_fudge_factor = 1.2 + box_size = np.array([(limits[i][1] - limits[i][0]).value for i in range(3)]) + self.camera.position = self._user_camera.get( + 'position', list(np.array(center) + distance_fudge_factor * box_size) + ) + camera_dist = np.linalg.norm(np.array(self.camera.position) - np.array(center)) + box_mean_size = np.linalg.norm(box_size) + self.camera.near = self._user_camera.get('near', 0.01 * box_mean_size) + camera_to_origin = np.linalg.norm( + np.array(self.camera.position) - np.array([0, 0, 0]) + ) + center_to_origin = np.linalg.norm(np.array(center) - np.array([0, 0, 0])) + self.camera.far = self._user_camera.get( + 'far', + 5 * max(box_mean_size, camera_dist, camera_to_origin, center_to_origin), + ) + self.camera.lookAt(look_at) + + self._backup_camera_settings( + center, look_at, view_distance=distance_fudge_factor * box_mean_size + ) + + def _update_orthographic_camera( + self, + limits: tuple[sc.Variable, sc.Variable, sc.Variable], + center: list[float], + look_at: tuple[float, float, float], + ) -> None: + distance_fudge_factor = 0.05 + xyz_min = min(lim.values.min() for lim in limits) + xyz_max = max(lim.values.max() for lim in limits) + d_xyz = xyz_max - xyz_min + xyz_min -= d_xyz * distance_fudge_factor + xyz_max += d_xyz * distance_fudge_factor + self.camera.left = xyz_min + self.camera.right = xyz_max + self.camera.top = xyz_max + self.camera.bottom = xyz_min + + view_distance = xyz_min + 2 * d_xyz + + self.camera.position = self._user_camera.get('position', [0, 0, -view_distance]) + self.camera.near = self._user_camera.get('near', 0.001 * d_xyz) + self.camera.far = self._user_camera.get('far', 1000 * d_xyz) + self.camera.lookAt(self._user_camera.get('look_at', look_at)) + + self._backup_camera_settings(center, look_at, view_distance=view_distance) + + def draw(self) -> None: """ Create an outline box with ticklabels, given a range in the XYZ directions. """ @@ -143,46 +228,14 @@ def draw(self): ) center = [var.mean().value for var in limits] - distance_fudge_factor = 1.2 - # TODO: if orthographic camera - box_size = np.array([(limits[i][1] - limits[i][0]).value for i in range(3)]) - self.camera.position = self._user_camera.get( - 'position', list(np.array(center) + distance_fudge_factor * box_size) - ) - camera_dist = np.linalg.norm(np.array(self.camera.position) - np.array(center)) - box_mean_size = np.linalg.norm(box_size) - self.camera.near = self._user_camera.get('near', 0.01 * box_mean_size) - camera_to_origin = np.linalg.norm( - np.array(self.camera.position) - np.array([0, 0, 0]) - ) - center_to_origin = np.linalg.norm(np.array(center) - np.array([0, 0, 0])) - self.camera.far = self._user_camera.get( - 'far', - 5 * max(box_mean_size, camera_dist, camera_to_origin, center_to_origin), - ) camera_lookat = tuple(self._user_camera.get('look_at', center)) - self.controls.target = camera_lookat - self.camera.lookAt(camera_lookat) - # Save camera settings - self.camera_backup["reset"] = copy(self.camera.position) - self.camera_backup["look_at"] = copy(camera_lookat) - self.camera_backup["center"] = tuple(copy(center)) - self.camera_backup["x_normal"] = [ - center[0] - distance_fudge_factor * box_mean_size, - center[1], - center[2], - ] - self.camera_backup["y_normal"] = [ - center[0], - center[1] - distance_fudge_factor * box_mean_size, - center[2], - ] - self.camera_backup["z_normal"] = [ - center[0], - center[1], - center[2] - distance_fudge_factor * box_mean_size, - ] + if self._perspective: + self._update_perspective_camera(limits, center, camera_lookat) + else: + self._update_orthographic_camera(limits, center, camera_lookat) + + self.controls.target = camera_lookat self.axes_3d.scale = [self.camera.far] * 3 @property From 47789e7ccb92d9eb3f2291c71650004b3c1d4771 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 20:57:13 +0100 Subject: [PATCH 3/5] add tests --- .../pythreejs/pythreejs_canvas_test.py | 20 +++++++++++-------- tests/plotting/scatter3d_test.py | 5 +++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/backends/pythreejs/pythreejs_canvas_test.py b/tests/backends/pythreejs/pythreejs_canvas_test.py index d1ee0cb1..79db906e 100644 --- a/tests/backends/pythreejs/pythreejs_canvas_test.py +++ b/tests/backends/pythreejs/pythreejs_canvas_test.py @@ -37,16 +37,18 @@ def test_figsize(): assert canvas.renderer.height == 450 -def test_camera_home(): - canvas = _make_figure().canvas +@pytest.mark.parametrize('perspective', [True, False]) +def test_camera_home(perspective): + canvas = _make_figure(perspective=perspective).canvas original = copy(canvas.camera.position) canvas.camera.position = [10, 20, 30] canvas.home() assert canvas.camera.position == original -def test_camera_x(): - canvas = _make_figure().canvas +@pytest.mark.parametrize('perspective', [True, False]) +def test_camera_x(perspective): + canvas = _make_figure(perspective=perspective).canvas center = copy(canvas.camera_backup["center"]) canvas.camera.position = [10, 20, 30] canvas.camera_x_normal() @@ -59,8 +61,9 @@ def test_camera_x(): assert canvas.camera.position[2] == center[2] -def test_camera_y(): - canvas = _make_figure().canvas +@pytest.mark.parametrize('perspective', [True, False]) +def test_camera_y(perspective): + canvas = _make_figure(perspective=perspective).canvas center = copy(canvas.camera_backup["center"]) canvas.camera.position = [10, 20, 30] canvas.camera_y_normal() @@ -73,8 +76,9 @@ def test_camera_y(): assert canvas.camera.position[2] == center[2] -def test_camera_z(): - canvas = _make_figure().canvas +@pytest.mark.parametrize('perspective', [True, False]) +def test_camera_z(perspective): + canvas = _make_figure(perspective=perspective).canvas center = copy(canvas.camera_backup["center"]) canvas.camera.position = [10, 20, 30] canvas.camera_z_normal() diff --git a/tests/plotting/scatter3d_test.py b/tests/plotting/scatter3d_test.py index 08ee91e2..3dd1d039 100644 --- a/tests/plotting/scatter3d_test.py +++ b/tests/plotting/scatter3d_test.py @@ -161,3 +161,8 @@ def test_figure_has_only_unit_on_colorbar_for_multiple_sets_of_scatter_points(): assert str(b.unit) in ylabel assert a.name not in ylabel assert b.name not in ylabel + + +def test_scatter3d_no_perspective(): + da = scatter() + pp.scatter3d(da, perspective=False) From 190f7156602cc47a252d46ccec629b1ba210c08b Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 21:05:43 +0100 Subject: [PATCH 4/5] revert move of import --- src/plopp/backends/pythreejs/canvas.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index caade70e..0d2bd909 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -8,7 +8,6 @@ import ipywidgets as ipw import numpy as np -import pythreejs as p3 import scipp as sc from ...graphics import Camera @@ -42,7 +41,7 @@ def __init__( perspective: bool = True, **ignored, ): - # import pythreejs as p3 + import pythreejs as p3 self.dims = {} self.units = {} From 763da37c4d9dd77d35847d6244b2c59278c5bf65 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 4 Mar 2026 21:09:08 +0100 Subject: [PATCH 5/5] remove toggle perspective function which does not work --- src/plopp/backends/pythreejs/canvas.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index 0d2bd909..7811103d 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -310,14 +310,6 @@ def toggle_axes3d(self): """ self.axes_3d.visible = not self.axes_3d.visible - def toggle_perspective(self): - self.remove(self.camera) - self.camera = p3.OrthographicCamera( - far=self.camera.far, near=self.camera.far * 1e-5 - ) - self.add(self.camera) - self.draw() - def add(self, obj: Any): """ Add an object to the ``scene``.