diff --git a/docs/changelog.rst b/docs/changelog.rst index 7b2b9055..489907a6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,6 +39,12 @@ Unreleased 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. +- In the interactive viewer, changing the frame or a display style no + longer resets the zoom level. + +- A view opened with a zoom above one now starts zoomed in at that + level, rather than fitting the window regardless of the zoom set. + 0.20.0 ------ diff --git a/src/hofmann/rendering/interactive.py b/src/hofmann/rendering/interactive.py index abf99e1e..639c39ca 100644 --- a/src/hofmann/rendering/interactive.py +++ b/src/hofmann/rendering/interactive.py @@ -65,7 +65,7 @@ def _rotation_z(angle: float) -> np.ndarray: _KEY_ROTATION_STEP = 0.05 # radians (~3 degrees) per key press _KEY_ZOOM_FACTOR = 1.1 # multiplicative zoom per key press / scroll step -_KEY_PAN_FRACTION = 0.05 # fraction of scene extent per key press +_KEY_PAN_FRACTION = 0.05 # fraction of the viewport panned per key press _PERSPECTIVE_STEP = 0.1 # perspective increment per key press _PERSPECTIVE_FLOOR = 1e-9 # below this, the descent lands on Orthographic _DISTANCE_FACTOR = 1.05 # viewing distance multiplier per key press diff --git a/src/hofmann/rendering/projection.py b/src/hofmann/rendering/projection.py index 38b8c1bc..82f3cbee 100644 --- a/src/hofmann/rendering/projection.py +++ b/src/hofmann/rendering/projection.py @@ -69,7 +69,11 @@ def _scene_extent( Returns the radius of a 2D bounding circle centred at the origin that encloses every atom and unit-cell corner under any rotation: the largest centre distance plus display radius, widened by the - projection's worst-case magnification, and scaled by zoom. + projection's worst-case magnification. + + The extent is in scene units and excludes ``view.zoom``: the + projected coordinates already carry it, so consumers must not + re-apply it. """ coords = scene.frames[frame_index].coords dists = np.linalg.norm(coords - view.centre, axis=1) @@ -104,7 +108,7 @@ def _scene_extent( # not a magic-threshold clamp on the magnification. max_extent *= view.projection.max_magnification(max_extent) - return float(max_extent * view.zoom) + return float(max_extent) def _make_wedges( diff --git a/tests/test_rendering/test_interactive.py b/tests/test_rendering/test_interactive.py index aefe2252..5586fc92 100644 --- a/tests/test_rendering/test_interactive.py +++ b/tests/test_rendering/test_interactive.py @@ -201,6 +201,24 @@ def test_pan_down(self): expected = old_centre + step * view.rotation[1] np.testing.assert_allclose(view.centre, expected) + def test_pan_step_halves_when_zoom_doubles(self): + """The pan step is a fraction of the *visible* extent, so doubling + the zoom halves the world-space step. Guards the ``/ view.zoom`` + in the pan step (a ratio, not the production formula restated).""" + v1, s1, st1, iv1 = _key_action_fixtures() + v1.zoom = 1.0 + c1 = v1.centre.copy() + _do_key("shift+left", v1, s1, st1, iv1, base_extent=10.0) + step_at_1 = float(np.linalg.norm(v1.centre - c1)) + + v2, s2, st2, iv2 = _key_action_fixtures() + v2.zoom = 2.0 + c2 = v2.centre.copy() + _do_key("shift+left", v2, s2, st2, iv2, base_extent=10.0) + step_at_2 = float(np.linalg.norm(v2.centre - c2)) + + np.testing.assert_allclose(step_at_2, step_at_1 / 2.0) + # -- Perspective -- def test_perspective_increase(self): diff --git a/tests/test_rendering/test_projection.py b/tests/test_rendering/test_projection.py index 309ea28f..b56a02d7 100644 --- a/tests/test_rendering/test_projection.py +++ b/tests/test_rendering/test_projection.py @@ -202,6 +202,25 @@ def test_empty_scene_perspective_is_magnified(self): extent = _scene_extent(scene, view, 0, atom_scale=0.5) assert extent > 1.0 # pre-fix: gated out, returns the 1.0 floor + def test_extent_is_independent_of_zoom(self): + # The extent is in scene units; the coordinates carry the zoom. + # A frame-navigation recompute after zooming therefore returns + # the same viewport, so the interactive zoom is not reset. + 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))}, + ) + e1 = _scene_extent( + scene, ViewState(zoom=1.0, projection=Orthographic()), 0, + atom_scale=0.5, + ) + e2 = _scene_extent( + scene, ViewState(zoom=2.0, projection=Orthographic()), 0, + atom_scale=0.5, + ) + assert e1 == e2 + class TestMakeWedges: def test_pure_composition_returns_single_full_circle(self):