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
37 changes: 37 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: tests

on:
push:
pull_request:

permissions:
contents: read

jobs:
tests:
# pinned, not ubuntu-latest: no 3.8 or 3.9 build is published for the
# newer runner images, and both versions are supported here
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install --upgrade pip
- run: pip install -e '.[test]'
- run: pytest

docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- run: python -m pip install --upgrade pip
- run: pip install -e '.[docs]'
- run: make -C docs html
5 changes: 4 additions & 1 deletion docs/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Minimal makefile for Sphinx documentation

SPHINXOPTS ?=
# -W turns Sphinx warnings into errors, so a broken cross-reference or a page
# missing from a toctree fails the build instead of passing quietly; ?= keeps
# it overridable while drafting.
SPHINXOPTS ?= -W
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
Expand Down
69 changes: 53 additions & 16 deletions pyLEAFS/viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
reaches into private buffers. The grid's ``D`` selects the axes it builds.
"""

import functools

import numpy as np

from pyLEAFS import palette
Expand All @@ -38,21 +40,56 @@
_PANEL_WIDTH = 0.318
_SCENE_RECT = (0.023, 0.021, 0.640, 0.879)

# The widest and tallest the world box's projection ever gets, as a fraction of
# the 3d axes it lives in, taken over every elevation and azimuth: a box seen
# down a body diagonal covers far more of the axes than one seen face-on, so
# the 3d axes is inflated by the reciprocal of the *worst* case. Fitting the
# default view instead would leave the box clipped as soon as it was dragged.
# The view angles at which the world box's projection is widest and tallest: a
# box seen down a body diagonal covers far more of the 3d axes than one seen
# face-on, so the axes is inflated by the reciprocal of the *worst* case.
# Fitting the default view instead would leave the box clipped as soon as it
# was dragged.
#
# Both numbers are closed-form maxima rather than the largest value on a
# sampled sweep, which straddles the true extreme without ever landing on it.
# mplot3d normalises any world to a box of aspect 4:4:3 and looks at it from a
# fixed distance, so the widest view is down the z axis with the near face
# turned corner-on, and the tallest looks along the box's own body diagonal,
# whose direction sets elev = atan(4 * sqrt(2) / 3). Both worst cases are
# Both are closed-form extremes rather than the largest value on a sampled
# sweep, which straddles the true extreme without ever landing on it. mplot3d
# normalises any world to a box of aspect 4:4:3 and looks at it from a fixed
# distance, so the widest view is down the z axis with the near face turned
# corner-on, and the tallest looks along the box's own body diagonal, whose
# direction sets elev = atan(4 * sqrt(2) / 3). Both worst cases are
# independent of the world's own shape, because of that normalisation.
_WORST_VIEW_3D = ((90.0, 45.0), (62.0616, 45.0)) # widest, tallest
_SCENE_FILL_3D = (0.9526, 1.0302)


@functools.lru_cache(maxsize=None)
def _scene_fill_3d():
"""How much of a 3d axes the world box covers, at its widest and tallest.

Measured from mplot3d's own projection rather than written down, because
the size of that box is a matplotlib detail that has moved: 3.8 scaled the
normalised box aspect up by 25/24, and under the default perspective
projection that enlargement is not a plain rescaling of the picture, so
both fractions grow, and by slightly different amounts.

The result is a property of mplot3d alone - it does not depend on the
world's shape, the figure size, or where the axes sits - so it is measured
once, off-screen, on a unit box.
"""
from matplotlib.figure import Figure
from mpl_toolkits.mplot3d import proj3d

fig = Figure()
ax = fig.add_axes((0.0, 0.0, 1.0, 1.0), projection="3d")
ax.set_xlim3d(0.0, 1.0)
ax.set_ylim3d(0.0, 1.0)
ax.set_zlim3d(0.0, 1.0)
corners = np.array([(x, y, z) for x in (0.0, 1.0)
for y in (0.0, 1.0) for z in (0.0, 1.0)])

fill = np.zeros(2)
for elev, azim in _WORST_VIEW_3D:
ax.view_init(elev, azim)
x, y, _ = proj3d.proj_transform(corners[:, 0], corners[:, 1],
corners[:, 2], ax.get_proj())
drawn = ax.transData.transform(np.column_stack([x, y]))
span = drawn.max(axis=0) - drawn.min(axis=0)
fill = np.maximum(fill, span / [ax.bbox.width, ax.bbox.height])
return (float(fill[0]), float(fill[1]))


def _inflate(rect, fx, fy):
Expand All @@ -66,8 +103,8 @@ def _unclipped(artist):

Matplotlib squares off a 3d axes and clips its artists to that square,
which is shorter than the box's own projection at a steep elevation; the
tip of the world would be sliced off mid-drag. ``_SCENE_FILL_3D`` is what
keeps the box inside the scene rectangle instead.
tip of the world would be sliced off mid-drag. :func:`_scene_fill_3d` is
what keeps the box inside the scene rectangle instead.
"""
artist.set_clip_on(False)
return artist
Expand Down Expand Up @@ -160,9 +197,9 @@ def _build_figure(self):
self.ax = self.fig.add_axes(_SCENE_RECT)
self._build_axes_2d()
else:
fill = _scene_fill_3d()
self.ax = self.fig.add_axes(
_inflate(_SCENE_RECT, 1.0 / _SCENE_FILL_3D[0],
1.0 / _SCENE_FILL_3D[1]),
_inflate(_SCENE_RECT, 1.0 / fill[0], 1.0 / fill[1]),
projection="3d")
self._build_axes_3d()
self._title = self.fig.text(x + 0.5 * w, y + h + 0.038, "",
Expand Down
11 changes: 7 additions & 4 deletions tests/test_pyLEAFS.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from pyLEAFS import (Grid, ResourceField, SpatialHash, Population, # noqa: E402
Simulation, Viewer, palette)
from pyLEAFS.viewer import _SCENE_FILL_3D, _WORST_VIEW_3D # noqa: E402
from pyLEAFS.viewer import _scene_fill_3d, _WORST_VIEW_3D # noqa: E402


# ----------------------------------------------------------------- Grid
Expand Down Expand Up @@ -426,7 +426,7 @@ def test_viewer_3d_box_stays_in_the_window_at_every_view_angle():


def test_viewer_3d_fill_is_the_true_worst_case_over_all_view_angles():
# the fill constants must be the largest the box ever gets, or the window
# the measured fill must be the largest the box ever gets, or the window
# is sized for a view the user can rotate straight past
sim = Simulation.forager(seed=0, shape=(4, 4, 4))
v = _viewer(sim)
Expand All @@ -439,8 +439,11 @@ def test_viewer_3d_fill_is_the_true_worst_case_over_all_view_angles():
drawn = _scene_corners(v)
span = drawn.max(axis=0) - drawn.min(axis=0)
fill = np.maximum(fill, span / [box.width, box.height])
assert np.all(fill <= _SCENE_FILL_3D) # nothing overflows
assert np.allclose(fill, _SCENE_FILL_3D, atol=5e-4) # and nothing is lost
# the sweep repeats the worst-case projection, so it lands on the measured
# value to within pixel-transform rounding rather than under it
worst = np.array(_scene_fill_3d())
assert np.all(fill <= worst + 1e-9) # nothing overflows
assert np.allclose(fill, worst, atol=5e-4) # and nothing is lost
plt.close(v.fig)


Expand Down
Loading