From 2d182443ee47a87d9ec6bb7cf6e4b4ceb80e5145 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 16 Aug 2026 13:35:34 +0100 Subject: [PATCH 1/6] Add a Projection ABC the variants implement --- src/hofmann/model/view_state.py | 98 +++++++++++++++++++++++++++-- tests/test_model/test_view_state.py | 58 ++++++++++++++++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/src/hofmann/model/view_state.py b/src/hofmann/model/view_state.py index 69b9cae1..9e63e9ac 100644 --- a/src/hofmann/model/view_state.py +++ b/src/hofmann/model/view_state.py @@ -2,19 +2,79 @@ import math import warnings +from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import assert_never import numpy as np +#: Stand-in eye distance for parallel projections: far enough that all +#: view rays are effectively parallel (matching XBS pmode == 0). +_PARALLEL_EYE_DISTANCE = 1e6 + + +class Projection(ABC): + """A camera projection mode. + + Concrete variants map camera-space geometry to the screen; see + :class:`Orthographic` and :class:`Perspective`. + """ + + # Empty slots keep the ABC slots-friendly: without it, subclasses + # declaring ``slots=True`` would still gain a ``__dict__`` from this + # base, so a mistyped attribute would silently stick. + __slots__ = () + + @abstractmethod + def to_screen(self, camera: np.ndarray) -> np.ndarray: + """Map camera-space ``(n, 3)`` to screen-space ``(n, 2)``, before zoom.""" + + @abstractmethod + def silhouette_radius( + self, depth: np.ndarray, radii: np.ndarray, + ) -> np.ndarray: + """Screen-space sphere silhouette radii, before zoom.""" + + @abstractmethod + def max_magnification(self, worst_depth: float) -> float: + """Worst-case screen magnification for a point at that depth.""" + + @property + @abstractmethod + def eye_distance(self) -> float: + """Reference eye distance for bond-cap foreshortening.""" + + @abstractmethod + def reaches_eye_plane(self, depth: np.ndarray) -> bool: + """Whether any point is at or behind the eye (a degenerate view).""" + + @dataclass(frozen=True, slots=True) -class Orthographic: +class Orthographic(Projection): """Parallel projection: depth is not foreshortened.""" + def to_screen(self, camera: np.ndarray) -> np.ndarray: + return camera[:, :2] + + def silhouette_radius( + self, depth: np.ndarray, radii: np.ndarray, + ) -> np.ndarray: + return radii + + def max_magnification(self, worst_depth: float) -> float: + return 1.0 + + @property + def eye_distance(self) -> float: + return _PARALLEL_EYE_DISTANCE + + def reaches_eye_plane(self, depth: np.ndarray) -> bool: + return False + @dataclass(frozen=True, slots=True) -class Perspective: +class Perspective(Projection): """Perspective projection with the eye on the camera's +z axis. Screen positions are scaled by ``D / (D - z * s)`` for an atom at @@ -46,9 +106,39 @@ def __post_init__(self) -> None: f"{self.view_distance}" ) + def to_screen(self, camera: np.ndarray) -> np.ndarray: + d = self.view_distance - camera[:, 2] * self.strength + with np.errstate(divide="ignore", invalid="ignore"): + # Not clamped: a point at or behind the eye yields an + # infinite or negative scale, left as-is so the geometry + # stays reproducible. ViewState.project_camera warns. + return camera[:, :2] * (self.view_distance / d)[:, np.newaxis] + + def silhouette_radius( + self, depth: np.ndarray, radii: np.ndarray, + ) -> np.ndarray: + # Recomputed rather than recovered from to_screen's scale: that + # division round trip is not bit-exact. + d = self.view_distance - depth * self.strength + # Silhouette radius: r * D / sqrt(d^2 - r^2). Approximate: the + # eye is at D / strength, for which the exact form carries + # (r * strength)^2. Bond end caps use the same reference + # distance D (see bond_geometry), so the two share an eye and + # must be corrected together. + denom = np.sqrt(np.maximum(d**2 - radii**2, 1e-12)) + return radii * self.view_distance / denom + + def max_magnification(self, worst_depth: float) -> float: + denom = self.view_distance - worst_depth * self.strength + return self.view_distance / (denom if denom > 0 else 1e-6) + + @property + def eye_distance(self) -> float: + return self.view_distance + + def reaches_eye_plane(self, depth: np.ndarray) -> bool: + return bool(np.any(self.view_distance - depth * self.strength <= 0)) -#: The projection modes, as a single name for annotations. -Projection = Orthographic | Perspective #: Default perspective, so the setter and the type cannot drift apart. _DEFAULT_PERSPECTIVE = Perspective() diff --git a/tests/test_model/test_view_state.py b/tests/test_model/test_view_state.py index eefca722..f452e4be 100644 --- a/tests/test_model/test_view_state.py +++ b/tests/test_model/test_view_state.py @@ -6,7 +6,12 @@ import numpy as np import pytest -from hofmann.model.view_state import Orthographic, Perspective, ViewState +from hofmann.model.view_state import ( + Orthographic, + Perspective, + Projection, + ViewState, +) class TestViewStateProject: @@ -388,3 +393,54 @@ def test_modes_compare_by_value(self): assert Perspective(0.5, 10.0) == Perspective(0.5, 10.0) assert Perspective(0.5, 10.0) != Perspective(0.6, 10.0) assert Orthographic() == Orthographic() + + def test_projection_is_abstract_and_enforces_the_interface(self): + """A variant missing any method cannot be instantiated.""" + + class Incomplete(Projection): + pass + + with pytest.raises(TypeError, match="abstract"): + Incomplete() + + def test_max_magnification_passes_small_positive_denominators(self): + """A denominator in (0, 1e-6) is not clamped. + + Pins the ``denom if denom > 0 else 1e-6`` form against the + tempting ``max(denom, 1e-6)``, which would flatten this band: + with strength 1 and view_distance 10, worst_depth 10 - 5e-7 + leaves denom = 5e-7, so the magnification is ~2e7, not the 1e7 + that clamping to 1e-6 would give. + """ + persp = Perspective(strength=1.0, view_distance=10.0) + mag = persp.max_magnification(10.0 - 5e-7) + assert mag == pytest.approx(2e7, rel=1e-3) + assert mag > 1.5e7 # would be 1e7 if the denominator were clamped + + def test_variants_answer_their_contract(self): + ortho = Orthographic() + assert ortho.eye_distance == 1e6 + assert ortho.reaches_eye_plane(np.array([1e9])) is False + np.testing.assert_array_equal( + ortho.to_screen(np.array([[2.0, 3.0, 5.0]])), [[2.0, 3.0]] + ) + np.testing.assert_array_equal( + ortho.silhouette_radius(np.array([5.0]), np.array([1.5])), [1.5] + ) + assert ortho.max_magnification(100.0) == 1.0 + + persp = Perspective(1.0, 10.0) + assert persp.eye_distance == 10.0 + assert persp.reaches_eye_plane(np.array([9.0])) is False + assert persp.reaches_eye_plane(np.array([11.0])) is True + # depth 5: d = 10 - 5 = 5, scale = 10/5 = 2 + np.testing.assert_allclose( + persp.to_screen(np.array([[1.0, 0.0, 5.0]])), [[2.0, 0.0]] + ) + # silhouette: r*D/sqrt(D^2 - r^2) = 10/sqrt(99) + np.testing.assert_allclose( + persp.silhouette_radius(np.array([0.0]), np.array([1.0])), + [10.0 / np.sqrt(99.0)], + ) + # worst depth 5: D/(D - 5*s) = 10/5 = 2 + assert persp.max_magnification(5.0) == 2.0 From f9ba4d391e3567054100a6f7bf8f6a10a597ea89 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 16 Aug 2026 14:09:04 +0100 Subject: [PATCH 2/6] Route ViewState through the projection variants; drop the dead scale --- src/hofmann/model/view_state.py | 106 +++++++----------------- src/hofmann/rendering/bond_geometry.py | 4 +- src/hofmann/rendering/cell_edges.py | 2 +- src/hofmann/rendering/projection.py | 8 +- tests/test_model/test_view_state.py | 8 ++ tests/test_rendering/test_projection.py | 13 ++- 6 files changed, 48 insertions(+), 93 deletions(-) diff --git a/src/hofmann/model/view_state.py b/src/hofmann/model/view_state.py index 9e63e9ac..a399654e 100644 --- a/src/hofmann/model/view_state.py +++ b/src/hofmann/model/view_state.py @@ -4,7 +4,6 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import assert_never import numpy as np @@ -108,23 +107,20 @@ def __post_init__(self) -> None: def to_screen(self, camera: np.ndarray) -> np.ndarray: d = self.view_distance - camera[:, 2] * self.strength + # errstate: at or behind the eye plane the divisor is 0 or + # negative, producing inf/negative on purpose (project_camera + # warns), so numpy's divide/invalid warnings are silenced. with np.errstate(divide="ignore", invalid="ignore"): - # Not clamped: a point at or behind the eye yields an - # infinite or negative scale, left as-is so the geometry - # stays reproducible. ViewState.project_camera warns. return camera[:, :2] * (self.view_distance / d)[:, np.newaxis] def silhouette_radius( self, depth: np.ndarray, radii: np.ndarray, ) -> np.ndarray: - # Recomputed rather than recovered from to_screen's scale: that - # division round trip is not bit-exact. d = self.view_distance - depth * self.strength - # Silhouette radius: r * D / sqrt(d^2 - r^2). Approximate: the - # eye is at D / strength, for which the exact form carries - # (r * strength)^2. Bond end caps use the same reference - # distance D (see bond_geometry), so the two share an eye and - # must be corrected together. + # Silhouette radius r*D/sqrt(d^2 - r^2), approximate: the eye is + # at D/strength, for which the exact form carries (r*strength)^2. + # Bond end caps use the same reference distance D (bond_geometry), + # so the two share an eye. denom = np.sqrt(np.maximum(d**2 - radii**2, 1e-12)) return radii * self.view_distance / denom @@ -140,7 +136,6 @@ def reaches_eye_plane(self, depth: np.ndarray) -> bool: return bool(np.any(self.view_distance - depth * self.strength <= 0)) -#: Default perspective, so the setter and the type cannot drift apart. _DEFAULT_PERSPECTIVE = Perspective() @@ -217,86 +212,43 @@ def project( centred = coords - self.centre rotated = centred @ self.rotation.T depth = rotated[:, 2] - xy, _ = self.project_camera(rotated) + xy = self.project_camera(rotated) if radii is None: return xy, depth, np.zeros(len(depth)) radii = np.asarray(radii, dtype=float) - match self.projection: - case Perspective() as p: - # Recomputed rather than recovered as view_distance / - # scale: that division round trip is not bit-exact. - d = p.view_distance - depth * p.strength - # Silhouette radius: r * D / sqrt(d^2 - r^2). - # Exact for an eye at D; the eye is at D / strength, - # for which the exact form carries (r * strength)^2. - # Left as-is deliberately: bond end caps foreshorten to - # the same reference distance D (see - # bond_geometry._foreshortening_distance), so correcting - # only the silhouette here would desync atom radii from - # the bonds meeting them. Both move together, or - # neither does. - denom = np.sqrt(np.maximum(d**2 - radii**2, 1e-12)) - projected_radii = radii * p.view_distance / denom * self.zoom - case Orthographic(): - projected_radii = radii * self.zoom - case _: - assert_never(self.projection) - - return xy, depth, projected_radii - - def project_camera( - self, camera: np.ndarray, - ) -> tuple[np.ndarray, np.ndarray]: + silhouette = self.projection.silhouette_radius(depth, radii) + return xy, depth, silhouette * self.zoom + + def project_camera(self, camera: np.ndarray) -> np.ndarray: """Map camera-space positions to screen positions. - This is the single camera-to-screen mapping for scene - geometry -- atoms, bonds, and cell edges all obtain screen - positions through it, so they stay consistent with each other. - Fixed-size screen furniture that deliberately ignores - :attr:`zoom`, such as the axes orientation widget, maps - directions itself and does not come through here. + The single camera-to-screen mapping for scene geometry: atoms, + bonds, and cell edges all obtain their screen positions here. Args: camera: Array of shape ``(n, 3)``, already centred and rotated into camera space. Returns: - Tuple of ``(xy, scale)`` where *xy* has shape ``(n, 2)`` - and *scale* has shape ``(n,)``, the perspective scale - applied at each point (all ones under a parallel - projection). + *xy* of shape ``(n, 2)`` — screen positions with zoom applied. """ camera = np.asarray(camera, dtype=float) - match self.projection: - case Perspective() as p: - denom = p.view_distance - camera[:, 2] * p.strength - if np.any(denom <= 0): - warnings.warn( - "one or more points lie at or behind the " - f"perspective eye plane (view_distance=" - f"{p.view_distance:g}, strength={p.strength:g}); " - "they are drawn mirrored through the origin and " - "sorted as if nearest the viewer. Increase " - "view_distance or reduce strength.", - UserWarning, - stacklevel=2, - ) - # Not clamped: the scale is left infinite or negative - # so the geometry stays reproducible, and the warning - # above is what tells the caller. - with np.errstate(divide="ignore", invalid="ignore"): - scale = p.view_distance / denom - xy = camera[:, :2] * scale[:, np.newaxis] * self.zoom - case Orthographic(): - # Reported as ones for the caller, but not applied: - # a parallel projection does not scale with depth. - scale = np.ones(len(camera)) - xy = camera[:, :2] * self.zoom - case _: - assert_never(self.projection) - return xy, scale + proj = self.projection + if proj.reaches_eye_plane(camera[:, 2]): + assert isinstance(proj, Perspective) # only Perspective reaches it + warnings.warn( + "one or more points lie at or behind the " + f"perspective eye plane (view_distance=" + f"{proj.view_distance:g}, strength={proj.strength:g}); " + "they are drawn mirrored through the origin and " + "sorted as if nearest the viewer. Increase " + "view_distance or reduce strength.", + UserWarning, + stacklevel=2, + ) + return proj.to_screen(camera) * self.zoom def set_orthographic(self) -> ViewState: """Draw without perspective foreshortening. diff --git a/src/hofmann/rendering/bond_geometry.py b/src/hofmann/rendering/bond_geometry.py index e4f6c7dc..9515f6bf 100644 --- a/src/hofmann/rendering/bond_geometry.py +++ b/src/hofmann/rendering/bond_geometry.py @@ -228,8 +228,8 @@ def _bond_polygon( # Project atom centres to 2D, then offset along the 2D bond # direction by the projected tangent distance. - atom_a_2d, _ = _project_point(p_a, view) - atom_b_2d, _ = _project_point(p_b, view) + atom_a_2d = _project_point(p_a, view) + atom_b_2d = _project_point(p_b, view) bond_2d = atom_b_2d - atom_a_2d bond_2d_len = np.linalg.norm(bond_2d) diff --git a/src/hofmann/rendering/cell_edges.py b/src/hofmann/rendering/cell_edges.py index a60c1945..9cd2fae2 100644 --- a/src/hofmann/rendering/cell_edges.py +++ b/src/hofmann/rendering/cell_edges.py @@ -304,7 +304,7 @@ def _collect_cell_edges( # call: one (2k, 3) array rather than k two-row ones. fracs = np.asarray(sub_fracs, dtype=float).ravel() sub_c = c_s + (c_e - c_s) * fracs[:, np.newaxis] - sub_xy = view.project_camera(sub_c)[0].reshape(-1, 2, 2) + sub_xy = view.project_camera(sub_c).reshape(-1, 2, 2) sub_depths = sub_c[:, 2].reshape(-1, 2) for (xy_s_i, xy_e_i), (z_s, z_e) in zip(sub_xy, sub_depths): diff --git a/src/hofmann/rendering/projection.py b/src/hofmann/rendering/projection.py index da6a83f1..ac281862 100644 --- a/src/hofmann/rendering/projection.py +++ b/src/hofmann/rendering/projection.py @@ -37,7 +37,7 @@ def _make_unit_circle(n: int) -> np.ndarray: def _project_point( pt: np.ndarray, view: ViewState, -) -> tuple[np.ndarray, float]: +) -> np.ndarray: """Project a single 3D rotated point to 2D screen coordinates. Args: @@ -45,11 +45,9 @@ def _project_point( view: The ViewState defining the projection. Returns: - Tuple of (xy, scale) where *xy* is the 2D position and *scale* - is the perspective scale factor at this depth. + The 2D screen position. """ - xy, scale = view.project_camera(np.asarray(pt, dtype=float)[np.newaxis]) - return xy[0], float(scale[0]) + return view.project_camera(np.asarray(pt, dtype=float)[np.newaxis])[0] # Fractional coordinates of the 8 unit cube corners. diff --git a/tests/test_model/test_view_state.py b/tests/test_model/test_view_state.py index f452e4be..14b59701 100644 --- a/tests/test_model/test_view_state.py +++ b/tests/test_model/test_view_state.py @@ -444,3 +444,11 @@ def test_variants_answer_their_contract(self): ) # worst depth 5: D/(D - 5*s) = 10/5 = 2 assert persp.max_magnification(5.0) == 2.0 + + def test_perspective_to_screen_not_clamped_at_or_behind_eye(self): + """At/behind the eye, positions blow up rather than clamp.""" + persp = Perspective(1.0, 10.0) + on_plane = persp.to_screen(np.array([[1.0, 0.0, 10.0]])) # depth = D + assert not np.all(np.isfinite(on_plane)) + behind = persp.to_screen(np.array([[1.0, 0.0, 11.0]])) # depth > D + assert behind[0, 0] < 0 # mirrored through the origin diff --git a/tests/test_rendering/test_projection.py b/tests/test_rendering/test_projection.py index cbf9deb4..a07c1253 100644 --- a/tests/test_rendering/test_projection.py +++ b/tests/test_rendering/test_projection.py @@ -30,15 +30,13 @@ class TestProjectPoint: def test_orthographic(self): view = ViewState() pt = np.array([1.0, 2.0, 3.0]) - xy, s = _project_point(pt, view) + xy = _project_point(pt, view) np.testing.assert_allclose(xy, [1.0, 2.0]) - assert s == 1.0 def test_perspective(self): view = ViewState(projection=Perspective(1.0, 10.0)) - pt = np.array([1.0, 0.0, 0.0]) # depth 0 -> scale = 1 - xy, s = _project_point(pt, view) - np.testing.assert_allclose(s, 1.0) + pt = np.array([1.0, 0.0, 0.0]) # depth 0 -> no foreshortening + xy = _project_point(pt, view) np.testing.assert_allclose(xy, [1.0, 0.0]) @@ -308,11 +306,10 @@ def test_project_point_agrees_with_project_camera(self): """_project_point is a scalar view of the same mapping.""" view = ViewState(zoom=1.4, projection=Perspective(0.7, 9.0)) camera = np.array([[1.0, -2.0, 3.0], [0.5, 0.25, -4.0]]) - batch_xy, batch_scale = view.project_camera(camera) + batch_xy = view.project_camera(camera) for i, point in enumerate(camera): - xy, scale = _project_point(point, view) + xy = _project_point(point, view) np.testing.assert_array_equal(xy, batch_xy[i]) - assert scale == batch_scale[i] class TestCellEdgeSubSegmentPairing: From 171eeef3eeed68055e3a679af6844543e40fd0ea Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 16 Aug 2026 14:21:44 +0100 Subject: [PATCH 3/6] Route the renderers through the projection variants --- src/hofmann/rendering/bond_geometry.py | 34 ++----------------- src/hofmann/rendering/projection.py | 47 ++++++++++---------------- 2 files changed, 21 insertions(+), 60 deletions(-) diff --git a/src/hofmann/rendering/bond_geometry.py b/src/hofmann/rendering/bond_geometry.py index 9515f6bf..da85d152 100644 --- a/src/hofmann/rendering/bond_geometry.py +++ b/src/hofmann/rendering/bond_geometry.py @@ -2,39 +2,11 @@ from __future__ import annotations -from typing import assert_never - import numpy as np -from hofmann.model import Orthographic, Perspective, ViewState +from hofmann.model import ViewState from hofmann.rendering.projection import _project_point -#: Stand-in eye distance for parallel projections: far enough that all -#: view rays are effectively parallel (matching XBS pmode == 0). -_PARALLEL_EYE_DISTANCE = 1e6 - - -def _foreshortening_distance(view: ViewState) -> float: - """Reference distance used to foreshorten bond end caps. - - Under :class:`Perspective` this is ``view_distance``, which is the - true eye position only at full strength: the eye actually sits at - ``view_distance / strength``, so the caps are foreshortened - towards a nearer point than the atoms are projected from at lower - strengths. - - A parallel projection has no eye at all, so it takes a stand-in - far enough away that the resulting view rays are parallel to - within a few parts in a million over a typical structure. - """ - match view.projection: - case Perspective() as p: - return p.view_distance - case Orthographic(): - return _PARALLEL_EYE_DISTANCE - case _: - assert_never(view.projection) - def _clip_bond_3d( p_a: np.ndarray, @@ -214,7 +186,7 @@ def _bond_polygon( # vector. This determines how much the arc squashes along the bond # direction (a bond pointing at the viewer has cth~1, one # perpendicular to the view has cth~0). - eye = np.array([0.0, 0.0, _foreshortening_distance(view)]) + eye = np.array([0.0, 0.0, view.projection.eye_distance]) q_a = eye - p_a q_b = eye - p_b denom_a = np.linalg.norm(q_a) * bond_len @@ -356,7 +328,7 @@ def _bond_polygons_batch( valid &= (bond_len_safe - w_a - w_b) > 0 # Foreshortening angles. - eye = np.array([0.0, 0.0, _foreshortening_distance(view)]) + eye = np.array([0.0, 0.0, view.projection.eye_distance]) q_a = eye - p_a # (n_bonds, 3) q_b = eye - p_b # (n_bonds, 3) q_a_len = np.linalg.norm(q_a, axis=1) # (n_bonds,) diff --git a/src/hofmann/rendering/projection.py b/src/hofmann/rendering/projection.py index ac281862..7a15c5d1 100644 --- a/src/hofmann/rendering/projection.py +++ b/src/hofmann/rendering/projection.py @@ -3,12 +3,10 @@ from __future__ import annotations import warnings -from typing import assert_never import numpy as np from hofmann.model import ( - Orthographic, Perspective, StructureScene, ViewState, @@ -72,9 +70,9 @@ def _scene_extent( """Compute rotation-invariant viewport half-extent for *scene*. Returns the radius of a 2D bounding circle centred at the origin - that encloses all atoms regardless of rotation. This is simply the - maximum 3D distance from the view centre plus the largest display - radius, scaled by zoom. + 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. """ coords = scene.frames[frame_index].coords dists = np.linalg.norm(coords - view.centre, axis=1) @@ -100,30 +98,21 @@ def _scene_extent( # 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). - match view.projection: - case Perspective() as p if len(dists) > 0: - worst_depth = float(np.max(dists)) - denom = p.view_distance - worst_depth * p.strength - if denom <= 0: - # The scene reaches the eye plane, the same degenerate - # case ViewState.project_camera warns about; here it - # would blow the viewport up to a blank canvas. - warnings.warn( - "the scene reaches the perspective eye plane " - f"(view_distance={p.view_distance:g}, " - f"strength={p.strength:g}); the view cannot be " - "sized and renders blank. Increase view_distance " - "or reduce strength.", - UserWarning, - stacklevel=2, - ) - # Not max(denom, 1e-6): that would alter 0 < denom < 1e-6. - persp_scale = p.view_distance / (denom if denom > 0 else 1e-6) - max_extent *= persp_scale - case Perspective() | Orthographic(): - pass - case _: - assert_never(view.projection) + 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) return float(max_extent * view.zoom) From fa983c5dd052a681ef64103c2de2bcd7cd305f71 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 16 Aug 2026 14:28:49 +0100 Subject: [PATCH 4/6] Move the projection types into their own module --- src/hofmann/model/__init__.py | 8 +- src/hofmann/model/projection.py | 137 ++++++++++++++++++++++++++++ src/hofmann/model/view_state.py | 130 +------------------------- tests/test_model/test_view_state.py | 8 +- 4 files changed, 142 insertions(+), 141 deletions(-) create mode 100644 src/hofmann/model/projection.py diff --git a/src/hofmann/model/__init__.py b/src/hofmann/model/__init__.py index d7e89361..b1339b71 100644 --- a/src/hofmann/model/__init__.py +++ b/src/hofmann/model/__init__.py @@ -29,13 +29,9 @@ _DEFAULT_CIRCLE_RADIUS, _DEFAULT_SPACING, ) +from hofmann.model.projection import Orthographic, Perspective, Projection from hofmann.model.structure_scene import StructureScene -from hofmann.model.view_state import ( - Orthographic, - Perspective, - Projection, - ViewState, -) +from hofmann.model.view_state import ViewState __all__ = [ "AtomLegendItem", diff --git a/src/hofmann/model/projection.py b/src/hofmann/model/projection.py new file mode 100644 index 00000000..e9b4d0f4 --- /dev/null +++ b/src/hofmann/model/projection.py @@ -0,0 +1,137 @@ +"""Projection modes for the camera: parallel and perspective.""" + +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + + +#: Stand-in eye distance for parallel projections: far enough that all +#: view rays are effectively parallel (matching XBS pmode == 0). +_PARALLEL_EYE_DISTANCE = 1e6 + + +class Projection(ABC): + """A camera projection mode. + + Concrete variants map camera-space geometry to the screen; see + :class:`Orthographic` and :class:`Perspective`. + """ + + # Empty slots keep the ABC slots-friendly: without it, subclasses + # declaring ``slots=True`` would still gain a ``__dict__`` from this + # base, so a mistyped attribute would silently stick. + __slots__ = () + + @abstractmethod + def to_screen(self, camera: np.ndarray) -> np.ndarray: + """Map camera-space ``(n, 3)`` to screen-space ``(n, 2)``, before zoom.""" + + @abstractmethod + def silhouette_radius( + self, depth: np.ndarray, radii: np.ndarray, + ) -> np.ndarray: + """Screen-space sphere silhouette radii, before zoom.""" + + @abstractmethod + def max_magnification(self, worst_depth: float) -> float: + """Worst-case screen magnification for a point at that depth.""" + + @property + @abstractmethod + def eye_distance(self) -> float: + """Reference eye distance for bond-cap foreshortening.""" + + @abstractmethod + def reaches_eye_plane(self, depth: np.ndarray) -> bool: + """Whether any point is at or behind the eye (a degenerate view).""" + + +@dataclass(frozen=True, slots=True) +class Orthographic(Projection): + """Parallel projection: depth is not foreshortened.""" + + def to_screen(self, camera: np.ndarray) -> np.ndarray: + return camera[:, :2] + + def silhouette_radius( + self, depth: np.ndarray, radii: np.ndarray, + ) -> np.ndarray: + return radii + + def max_magnification(self, worst_depth: float) -> float: + return 1.0 + + @property + def eye_distance(self) -> float: + return _PARALLEL_EYE_DISTANCE + + def reaches_eye_plane(self, depth: np.ndarray) -> bool: + return False + + +@dataclass(frozen=True, slots=True) +class Perspective(Projection): + """Perspective projection with the eye on the camera's +z axis. + + Screen positions are scaled by ``D / (D - z * s)`` for an atom at + camera depth *z*, writing *D* for :attr:`view_distance` and *s* + for :attr:`strength`. That places the eye at ``D / s``, so a + *strength* of ``1.0`` is a true pinhole camera at + :attr:`view_distance`; smaller values move the eye further out, + weakening the foreshortening. + + Attributes: + strength: Perspective strength. Must be positive; use + :class:`Orthographic` for a parallel projection. + view_distance: Reference distance from the scene centre, + equal to the eye distance at ``strength = 1``. + """ + + strength: float = 0.5 + view_distance: float = 10.0 + + def __post_init__(self) -> None: + if not math.isfinite(self.strength) or self.strength <= 0: + raise ValueError( + "strength must be finite and positive (use Orthographic " + f"for a parallel projection), got {self.strength}" + ) + if not math.isfinite(self.view_distance) or self.view_distance <= 0: + raise ValueError( + "view_distance must be finite and positive, got " + f"{self.view_distance}" + ) + + def to_screen(self, camera: np.ndarray) -> np.ndarray: + d = self.view_distance - camera[:, 2] * self.strength + # errstate: at or behind the eye plane the divisor is 0 or + # negative, producing inf/negative on purpose (project_camera + # warns), so numpy's divide/invalid warnings are silenced. + with np.errstate(divide="ignore", invalid="ignore"): + return camera[:, :2] * (self.view_distance / d)[:, np.newaxis] + + def silhouette_radius( + self, depth: np.ndarray, radii: np.ndarray, + ) -> np.ndarray: + d = self.view_distance - depth * self.strength + # Silhouette radius r*D/sqrt(d^2 - r^2), approximate: the eye is + # at D/strength, for which the exact form carries (r*strength)^2. + # Bond end caps use the same reference distance D (bond_geometry), + # so the two share an eye. + denom = np.sqrt(np.maximum(d**2 - radii**2, 1e-12)) + return radii * self.view_distance / denom + + def max_magnification(self, worst_depth: float) -> float: + denom = self.view_distance - worst_depth * self.strength + return self.view_distance / (denom if denom > 0 else 1e-6) + + @property + def eye_distance(self) -> float: + return self.view_distance + + def reaches_eye_plane(self, depth: np.ndarray) -> bool: + return bool(np.any(self.view_distance - depth * self.strength <= 0)) diff --git a/src/hofmann/model/view_state.py b/src/hofmann/model/view_state.py index a399654e..f0f54758 100644 --- a/src/hofmann/model/view_state.py +++ b/src/hofmann/model/view_state.py @@ -1,139 +1,11 @@ from __future__ import annotations -import math import warnings -from abc import ABC, abstractmethod from dataclasses import dataclass, field import numpy as np - -#: Stand-in eye distance for parallel projections: far enough that all -#: view rays are effectively parallel (matching XBS pmode == 0). -_PARALLEL_EYE_DISTANCE = 1e6 - - -class Projection(ABC): - """A camera projection mode. - - Concrete variants map camera-space geometry to the screen; see - :class:`Orthographic` and :class:`Perspective`. - """ - - # Empty slots keep the ABC slots-friendly: without it, subclasses - # declaring ``slots=True`` would still gain a ``__dict__`` from this - # base, so a mistyped attribute would silently stick. - __slots__ = () - - @abstractmethod - def to_screen(self, camera: np.ndarray) -> np.ndarray: - """Map camera-space ``(n, 3)`` to screen-space ``(n, 2)``, before zoom.""" - - @abstractmethod - def silhouette_radius( - self, depth: np.ndarray, radii: np.ndarray, - ) -> np.ndarray: - """Screen-space sphere silhouette radii, before zoom.""" - - @abstractmethod - def max_magnification(self, worst_depth: float) -> float: - """Worst-case screen magnification for a point at that depth.""" - - @property - @abstractmethod - def eye_distance(self) -> float: - """Reference eye distance for bond-cap foreshortening.""" - - @abstractmethod - def reaches_eye_plane(self, depth: np.ndarray) -> bool: - """Whether any point is at or behind the eye (a degenerate view).""" - - -@dataclass(frozen=True, slots=True) -class Orthographic(Projection): - """Parallel projection: depth is not foreshortened.""" - - def to_screen(self, camera: np.ndarray) -> np.ndarray: - return camera[:, :2] - - def silhouette_radius( - self, depth: np.ndarray, radii: np.ndarray, - ) -> np.ndarray: - return radii - - def max_magnification(self, worst_depth: float) -> float: - return 1.0 - - @property - def eye_distance(self) -> float: - return _PARALLEL_EYE_DISTANCE - - def reaches_eye_plane(self, depth: np.ndarray) -> bool: - return False - - -@dataclass(frozen=True, slots=True) -class Perspective(Projection): - """Perspective projection with the eye on the camera's +z axis. - - Screen positions are scaled by ``D / (D - z * s)`` for an atom at - camera depth *z*, writing *D* for :attr:`view_distance` and *s* - for :attr:`strength`. That places the eye at ``D / s``, so a - *strength* of ``1.0`` is a true pinhole camera at - :attr:`view_distance`; smaller values move the eye further out, - weakening the foreshortening. - - Attributes: - strength: Perspective strength. Must be positive; use - :class:`Orthographic` for a parallel projection. - view_distance: Reference distance from the scene centre, - equal to the eye distance at ``strength = 1``. - """ - - strength: float = 0.5 - view_distance: float = 10.0 - - def __post_init__(self) -> None: - if not math.isfinite(self.strength) or self.strength <= 0: - raise ValueError( - "strength must be finite and positive (use Orthographic " - f"for a parallel projection), got {self.strength}" - ) - if not math.isfinite(self.view_distance) or self.view_distance <= 0: - raise ValueError( - "view_distance must be finite and positive, got " - f"{self.view_distance}" - ) - - def to_screen(self, camera: np.ndarray) -> np.ndarray: - d = self.view_distance - camera[:, 2] * self.strength - # errstate: at or behind the eye plane the divisor is 0 or - # negative, producing inf/negative on purpose (project_camera - # warns), so numpy's divide/invalid warnings are silenced. - with np.errstate(divide="ignore", invalid="ignore"): - return camera[:, :2] * (self.view_distance / d)[:, np.newaxis] - - def silhouette_radius( - self, depth: np.ndarray, radii: np.ndarray, - ) -> np.ndarray: - d = self.view_distance - depth * self.strength - # Silhouette radius r*D/sqrt(d^2 - r^2), approximate: the eye is - # at D/strength, for which the exact form carries (r*strength)^2. - # Bond end caps use the same reference distance D (bond_geometry), - # so the two share an eye. - denom = np.sqrt(np.maximum(d**2 - radii**2, 1e-12)) - return radii * self.view_distance / denom - - def max_magnification(self, worst_depth: float) -> float: - denom = self.view_distance - worst_depth * self.strength - return self.view_distance / (denom if denom > 0 else 1e-6) - - @property - def eye_distance(self) -> float: - return self.view_distance - - def reaches_eye_plane(self, depth: np.ndarray) -> bool: - return bool(np.any(self.view_distance - depth * self.strength <= 0)) +from hofmann.model.projection import Orthographic, Perspective, Projection _DEFAULT_PERSPECTIVE = Perspective() diff --git a/tests/test_model/test_view_state.py b/tests/test_model/test_view_state.py index 14b59701..69f897e4 100644 --- a/tests/test_model/test_view_state.py +++ b/tests/test_model/test_view_state.py @@ -6,12 +6,8 @@ import numpy as np import pytest -from hofmann.model.view_state import ( - Orthographic, - Perspective, - Projection, - ViewState, -) +from hofmann.model.projection import Orthographic, Perspective, Projection +from hofmann.model.view_state import ViewState class TestViewStateProject: From 9b52e6cc8e536c11b7124e679b3bffed6380d570 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 16 Aug 2026 14:34:48 +0100 Subject: [PATCH 5/6] Document Projection as a class and pin its full interface Projection is now the ABC base rather than a union alias, so api.rst uses autoclass. The abstract-enforcement test previously used an empty subclass, which stays abstract while any one method is abstract -- so it did not catch a single method losing @abstractmethod, despite its docstring claiming "missing any method". Assert the exact abstract set instead, which fails if any of the five is no longer abstract. --- docs/api.rst | 8 ++++---- tests/test_model/test_view_state.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index bab1e917..92768fd3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -49,11 +49,11 @@ Data model .. autoclass:: Perspective :members: -.. data:: Projection +.. autoclass:: Projection - Type alias for the projection modes: - ``Orthographic | Perspective``. Use it to annotate code that - accepts any projection. + The abstract base of the projection modes (:class:`Orthographic`, + :class:`Perspective`); use it to annotate code that accepts any + projection. .. autoclass:: hofmann.model.atom_data.AtomData :members: n_atoms, ranges, labels diff --git a/tests/test_model/test_view_state.py b/tests/test_model/test_view_state.py index 69f897e4..a67cacbe 100644 --- a/tests/test_model/test_view_state.py +++ b/tests/test_model/test_view_state.py @@ -390,8 +390,15 @@ def test_modes_compare_by_value(self): assert Perspective(0.5, 10.0) != Perspective(0.6, 10.0) assert Orthographic() == Orthographic() - def test_projection_is_abstract_and_enforces_the_interface(self): - """A variant missing any method cannot be instantiated.""" + def test_projection_enforces_every_method(self): + """A variant omitting any one of the five cannot be instantiated.""" + assert Projection.__abstractmethods__ == frozenset({ + "to_screen", + "silhouette_radius", + "max_magnification", + "eye_distance", + "reaches_eye_plane", + }) class Incomplete(Projection): pass From 140ba4724ad3f46ed5a3776cbab20c58b8e3c42b Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 16 Aug 2026 15:25:13 +0100 Subject: [PATCH 6/6] Fold in three review nits - Qualify the cross-module `project_camera` reference in `Perspective.to_screen`'s comment, now that it lives in a different module from `ViewState`. - Note on `Perspective.eye_distance` that `view_distance` is the true eye only at full strength (the eye sits at `view_distance / strength`). - Split the projection-variant contract test per variant, so a first failure no longer masks the rest. --- src/hofmann/model/projection.py | 7 +++++-- tests/test_model/test_view_state.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hofmann/model/projection.py b/src/hofmann/model/projection.py index e9b4d0f4..be25f632 100644 --- a/src/hofmann/model/projection.py +++ b/src/hofmann/model/projection.py @@ -109,8 +109,9 @@ def __post_init__(self) -> None: def to_screen(self, camera: np.ndarray) -> np.ndarray: d = self.view_distance - camera[:, 2] * self.strength # errstate: at or behind the eye plane the divisor is 0 or - # negative, producing inf/negative on purpose (project_camera - # warns), so numpy's divide/invalid warnings are silenced. + # negative, producing inf/negative on purpose + # (ViewState.project_camera warns), so numpy's divide/invalid + # warnings are silenced. with np.errstate(divide="ignore", invalid="ignore"): return camera[:, :2] * (self.view_distance / d)[:, np.newaxis] @@ -131,6 +132,8 @@ def max_magnification(self, worst_depth: float) -> float: @property def eye_distance(self) -> float: + # Exact only at full strength: the eye actually sits at + # view_distance / strength. return self.view_distance def reaches_eye_plane(self, depth: np.ndarray) -> bool: diff --git a/tests/test_model/test_view_state.py b/tests/test_model/test_view_state.py index a67cacbe..9233321b 100644 --- a/tests/test_model/test_view_state.py +++ b/tests/test_model/test_view_state.py @@ -420,7 +420,7 @@ def test_max_magnification_passes_small_positive_denominators(self): assert mag == pytest.approx(2e7, rel=1e-3) assert mag > 1.5e7 # would be 1e7 if the denominator were clamped - def test_variants_answer_their_contract(self): + def test_orthographic_answers_its_contract(self): ortho = Orthographic() assert ortho.eye_distance == 1e6 assert ortho.reaches_eye_plane(np.array([1e9])) is False @@ -432,6 +432,7 @@ def test_variants_answer_their_contract(self): ) assert ortho.max_magnification(100.0) == 1.0 + def test_perspective_answers_its_contract(self): persp = Perspective(1.0, 10.0) assert persp.eye_distance == 10.0 assert persp.reaches_eye_plane(np.array([9.0])) is False