Skip to content
Merged
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
136 changes: 96 additions & 40 deletions src/plopp/backends/pythreejs/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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.

Expand All @@ -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.
"""
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/plopp/graphics/graphicalview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -105,6 +106,7 @@ def __init__(
title=title,
legend=legend,
camera=camera,
perspective=perspective,
ax=ax,
cax=cax,
xmin=xmin,
Expand Down
5 changes: 5 additions & 0 deletions src/plopp/plotting/scatter3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -159,6 +163,7 @@ def scatter3d(
nan_color=nan_color,
norm=norm,
opacity=opacity,
perspective=perspective,
title=title,
vmax=vmax,
vmin=vmin,
Expand Down
20 changes: 12 additions & 8 deletions tests/backends/pythreejs/pythreejs_canvas_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions tests/plotting/scatter3d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading