diff --git a/docs/changelog.rst b/docs/changelog.rst index 887848d1..7b2b9055 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,6 +35,10 @@ Unreleased corrected eye as the sphere silhouettes, so their drawn shape changes at perspective strengths other than one. +- Under perspective, the view is now sized so that unit-cell corners and + large atoms swung towards the eye by rotation no longer clip at the + viewport edge, and a cell with no atoms is sized correctly too. + 0.20.0 ------ diff --git a/src/hofmann/rendering/projection.py b/src/hofmann/rendering/projection.py index 7a15c5d1..38b8c1bc 100644 --- a/src/hofmann/rendering/projection.py +++ b/src/hofmann/rendering/projection.py @@ -2,12 +2,9 @@ from __future__ import annotations -import warnings - import numpy as np from hofmann.model import ( - Perspective, StructureScene, ViewState, ) @@ -94,25 +91,18 @@ def _scene_extent( if max_extent == 0.0: max_extent = 1.0 - # Under perspective, atoms near the camera appear larger. The - # worst-case magnification for an atom at distance *d* from the - # view centre is when it is rotated to depth z = +d (closest to - # the camera). - if len(dists) > 0: - worst_depth = float(np.max(dists)) - proj = view.projection - if proj.reaches_eye_plane(np.array([worst_depth])): - assert isinstance(proj, Perspective) # only Perspective reaches it - warnings.warn( - "the scene reaches the perspective eye plane " - f"(view_distance={proj.view_distance:g}, " - f"strength={proj.strength:g}); the view cannot be " - "sized and renders blank. Increase view_distance " - "or reduce strength.", - UserWarning, - stacklevel=2, - ) - max_extent *= proj.max_magnification(worst_depth) + # Under perspective, points nearer the camera are drawn larger. The + # worst case is a point at the bounding radius (max_extent — the + # furthest atom surface or cell corner) rotated closest to the eye, so + # size the allowance from max_extent itself. A cell corner or a large + # atom's surface can sit further out than any atom centre and would + # otherwise clip. + # + # Known limitation: this allowance grows without bound as the eye + # approaches the scene's bounding sphere, shrinking the scene to a + # dot. The honest fix is depth-based sizing with a near-plane clip, + # not a magic-threshold clamp on the magnification. + max_extent *= view.projection.max_magnification(max_extent) return float(max_extent * view.zoom) diff --git a/tests/test_rendering/test_projection.py b/tests/test_rendering/test_projection.py index a07c1253..309ea28f 100644 --- a/tests/test_rendering/test_projection.py +++ b/tests/test_rendering/test_projection.py @@ -1,7 +1,6 @@ """Tests for projection helpers — _project_point and _scene_extent.""" import math -import warnings import matplotlib.pyplot as plt import numpy as np @@ -60,31 +59,6 @@ def test_perspective_increases_extent(self): e_yes = _scene_extent(scene, view_persp, 0, atom_scale=0.5) assert e_yes > e_no - def test_scene_reaching_the_eye_plane_warns(self): - """The blank-canvas degenerate case must not be silent.""" - scene = StructureScene( - species=["C", "C"], - frames=[Frame(coords=np.array([ - [0.0, 0.0, -9.0], - [0.0, 0.0, 9.0], - ]))], - atom_styles={"C": AtomStyle(1.0, (0.5, 0.5, 0.5))}, - ) - view = ViewState(projection=Perspective(1.0, 9.0)) - with pytest.warns(UserWarning, match="eye plane"): - _scene_extent(scene, view, 0, atom_scale=0.5) - - def test_ordinary_perspective_scene_does_not_warn(self): - scene = StructureScene( - species=["C"], - frames=[Frame(coords=np.array([[0.0, 0.0, 0.0]]))], - atom_styles={"C": AtomStyle(1.0, (0.5, 0.5, 0.5))}, - ) - view = ViewState(projection=Perspective(0.5, 20.0)) - with warnings.catch_warnings(): - warnings.simplefilter("error") - _scene_extent(scene, view, 0, atom_scale=0.5) - def test_empty_scene(self): """An empty scene (zero atoms) should return a positive extent.""" scene = StructureScene( @@ -132,6 +106,102 @@ def test_lattice_extends_extent(self): e_lat = _scene_extent(scene_lat, view, 0, atom_scale=0.5) assert e_lat > e_no_lat + def test_orthographic_extent_is_the_bounding_radius(self): + # Guard: orthographic applies no magnification (max_magnification + # == 1.0), so the extent is exactly the bounding radius whatever + # the worst depth. Only perspective may move. + scene = StructureScene( + species=["C"], + frames=[Frame(coords=np.array([[0.0, 0.0, 5.0]]))], + atom_styles={"C": AtomStyle(1.0, (0.5, 0.5, 0.5))}, + ) + view = ViewState(projection=Orthographic()) + extent = _scene_extent(scene, view, 0, atom_scale=0.5) + assert extent == 5.0 + 1.0 * 0.5 # centre distance + display radius + + def test_cell_corner_drives_perspective_magnification(self): + # Atom at the centre (centre distance 0), so the pre-fix bound, + # magnifying from max(centre distances), gives no allowance; the + # far cell corner must drive the magnification instead. + scene = StructureScene( + species=["C"], + frames=[Frame( + coords=np.array([[0.0, 0.0, 0.0]]), + lattice=np.eye(3) * 10.0, + )], + atom_styles={"C": AtomStyle(0.5, (0.5, 0.5, 0.5))}, + ) + view = ViewState(projection=Perspective(0.5, 50.0)) + extent = _scene_extent(scene, view, 0, atom_scale=0.5) + corner_radius = math.sqrt(3 * 10.0**2) + assert extent > corner_radius # pre-fix: exactly corner_radius + + def test_atom_free_lattice_gets_perspective_allowance(self): + scene = StructureScene( + species=[], + frames=[Frame( + coords=np.empty((0, 3)), + lattice=np.eye(3) * 10.0, + )], + atom_styles={}, + ) + view = ViewState(projection=Perspective(0.5, 50.0)) + extent = _scene_extent(scene, view, 0, atom_scale=0.5) + corner_radius = math.sqrt(3 * 10.0**2) + assert extent > corner_radius # pre-fix: no magnification, == corner_radius + + def test_display_radius_drives_perspective_magnification(self): + # A large atom at the origin: its surface, not its centre + # (distance 0), is the outermost point and must drive the + # magnification. + scene = StructureScene( + species=["C"], + frames=[Frame(coords=np.array([[0.0, 0.0, 0.0]]))], + atom_styles={"C": AtomStyle(4.0, (0.5, 0.5, 0.5))}, + ) + view = ViewState(projection=Perspective(0.5, 50.0)) + extent = _scene_extent(scene, view, 0, atom_scale=0.5) + surface_radius = 4.0 * 0.5 # centre 0 + radius * atom_scale + assert extent > surface_radius # pre-fix: mag from centre 0, == surface_radius + + def test_extent_encloses_the_worst_case_rotation(self): + # The viewport must ENCLOSE the outermost atom at its worst + # (nearest-the-eye) rotation, not merely exceed the un-magnified + # radius: sweep rotations and confirm the rotation-invariant + # extent covers the largest projected offset the atom reaches. + coords = np.array([[3.0, 0.0, 4.0]]) # distance 5 from the origin + radii = np.array([0.01]) + scene = StructureScene( + species=["C"], + frames=[Frame(coords=coords)], + atom_styles={"C": AtomStyle(0.01, (0.5, 0.5, 0.5))}, + ) + persp = Perspective(1.0, 10.0) + extent = _scene_extent( + scene, ViewState(projection=persp), 0, atom_scale=1.0 + ) + worst = 0.0 + for angle in np.linspace(0.0, 2 * np.pi, 60): + c, s = np.cos(angle), np.sin(angle) + rot = np.array([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]) + xy, _, srad = ViewState( + rotation=rot, projection=persp + ).project(coords, radii) + worst = max(worst, float(np.hypot(xy[0, 0], xy[0, 1]) + srad[0])) + assert extent >= worst + + def test_empty_scene_perspective_is_magnified(self): + # No atoms and no lattice: max_extent falls back to 1.0, and the + # dropped atom-count gate now applies the perspective allowance. + scene = StructureScene( + species=[], + frames=[Frame(coords=np.empty((0, 3)))], + atom_styles={}, + ) + view = ViewState(projection=Perspective(0.5, 50.0)) + extent = _scene_extent(scene, view, 0, atom_scale=0.5) + assert extent > 1.0 # pre-fix: gated out, returns the 1.0 floor + class TestMakeWedges: def test_pure_composition_returns_single_full_circle(self):