diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..34b05ac --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/docs/Makefile b/docs/Makefile index dbeb553..17d862a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -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 diff --git a/pyLEAFS/viewer.py b/pyLEAFS/viewer.py index bc3cc5c..b0df9c9 100644 --- a/pyLEAFS/viewer.py +++ b/pyLEAFS/viewer.py @@ -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 @@ -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): @@ -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 @@ -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, "", diff --git a/tests/test_pyLEAFS.py b/tests/test_pyLEAFS.py index 1549218..c66c0cd 100644 --- a/tests/test_pyLEAFS.py +++ b/tests/test_pyLEAFS.py @@ -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 @@ -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) @@ -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)