diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index 4701d6f5..7811103d 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -38,6 +38,7 @@ def __init__( figsize: tuple[int, int] | None = None, title: str | None = None, camera: Camera | None = None, + perspective: bool = True, **ignored, ): import pythreejs as p3 @@ -58,7 +59,11 @@ 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) + 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() @@ -82,7 +87,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. @@ -99,7 +106,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. """ @@ -140,45 +227,14 @@ def draw(self): ) center = [var.mean().value for var in limits] - 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), - ) 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 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 a1d59ebe..7264fbff 100644 --- a/src/plopp/plotting/scatter3d.py +++ b/src/plopp/plotting/scatter3d.py @@ -55,6 +55,7 @@ def scatter3d( nan_color: str | None = None, norm: Literal['linear', 'log'] | None = None, opacity: float = 1.0, + perspective: bool = True, title: str | None = None, vmax: sc.Variable | float = None, vmin: sc.Variable | float = None, @@ -112,6 +113,9 @@ def scatter3d( ``True``). Legacy, prefer ``logc`` instead. opacity: The opacity (sometimes known as alpha) of the scatter points. + perspective: + Set to ``True`` for a perspective camera. ``False`` will give an orthographic + (flat) camera. title: The figure title. vmin: @@ -159,6 +163,7 @@ def scatter3d( nan_color=nan_color, norm=norm, opacity=opacity, + perspective=perspective, title=title, vmax=vmax, vmin=vmin, 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)