From 79850091ca50c0f64816c7c709c4bf77b5fc25ba Mon Sep 17 00:00:00 2001 From: Damian Sowinski Date: Wed, 19 Aug 2026 02:42:24 -0400 Subject: [PATCH 1/4] Run the tests and the documentation build on GitHub for every push and pull request --- .github/workflows/tests.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..97619e8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: tests + +on: + push: + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + 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 From 121b0232795013fda6c747949e861140f3aa9049 Mon Sep 17 00:00:00 2001 From: Damian Sowinski Date: Wed, 19 Aug 2026 10:11:42 -0400 Subject: [PATCH 2/4] Measure how much of the 3d axes the world box fills from matplotlib itself, so the viewer sizes its window correctly on matplotlib 3.7 as well --- pyLEAFS/viewer.py | 71 +++++++++++++++++++++++++++++++++---------- tests/test_pyLEAFS.py | 11 ++++--- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/pyLEAFS/viewer.py b/pyLEAFS/viewer.py index bc3cc5c..1b0d463 100644 --- a/pyLEAFS/viewer.py +++ b/pyLEAFS/viewer.py @@ -38,21 +38,60 @@ _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) + +_scene_fill_3d_cache = 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. + """ + global _scene_fill_3d_cache + if _scene_fill_3d_cache is None: + 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]) + _scene_fill_3d_cache = (float(fill[0]), float(fill[1])) + return _scene_fill_3d_cache def _inflate(rect, fx, fy): @@ -66,8 +105,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 +199,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) From f2bf5a4e739b3ce6432eca5e1a8fbf0a7740d799 Mon Sep 17 00:00:00 2001 From: Damian Sowinski Date: Wed, 19 Aug 2026 10:35:59 -0400 Subject: [PATCH 3/4] no-mistakes(review): Pin CI runner, add permissions, -W docs, lru_cache memo --- .github/workflows/tests.yml | 7 +++++- docs/Makefile | 5 +++- pyLEAFS/viewer.py | 49 +++++++++++++++++-------------------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 97619e8..34b05ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,9 +4,14 @@ on: push: pull_request: +permissions: + contents: read + jobs: tests: - runs-on: ubuntu-latest + # 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: 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 1b0d463..fe4f78c 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 @@ -53,9 +55,7 @@ # 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_cache = None - - +@functools.lru_cache(maxsize=None) def _scene_fill_3d(): """How much of a 3d axes the world box covers, at its widest and tallest. @@ -69,29 +69,26 @@ def _scene_fill_3d(): world's shape, the figure size, or where the axes sits - so it is measured once, off-screen, on a unit box. """ - global _scene_fill_3d_cache - if _scene_fill_3d_cache is None: - 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]) - _scene_fill_3d_cache = (float(fill[0]), float(fill[1])) - return _scene_fill_3d_cache + 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): From 4a802756bdf43d07c583337e7b963548a0415651 Mon Sep 17 00:00:00 2001 From: Damian Sowinski Date: Wed, 19 Aug 2026 11:03:45 -0400 Subject: [PATCH 4/4] no-mistakes(document): Fix E302 blank line in viewer; docs already accurate --- pyLEAFS/viewer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyLEAFS/viewer.py b/pyLEAFS/viewer.py index fe4f78c..b0df9c9 100644 --- a/pyLEAFS/viewer.py +++ b/pyLEAFS/viewer.py @@ -55,6 +55,7 @@ # independent of the world's own shape, because of that normalisation. _WORST_VIEW_3D = ((90.0, 45.0), (62.0616, 45.0)) # widest, tallest + @functools.lru_cache(maxsize=None) def _scene_fill_3d(): """How much of a 3d axes the world box covers, at its widest and tallest.