diff --git a/.gitignore b/.gitignore index 0058ab52..139e0885 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ htmlcov/ # Planning docs RESTRUCTURE.md PLAN-*.md +OBLIQUE_PROJECTION.md +.superpowers/ +docs/superpowers/ # OS .DS_Store diff --git a/README.md b/README.md index 31dd6e1f..bfba4ecc 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ scene.render_mpl("si.pdf") ```python scene.view.look_along([1, 1, 0]) # View along [110] scene.view.zoom = 1.5 # Zoom in -scene.view.perspective = 0.3 # Mild perspective +scene.view.set_perspective(0.3) # Mild perspective scene.render_mpl("rotated.svg") ``` diff --git a/docs/_static/generate_images.py b/docs/_static/generate_images.py index 479d196e..ba6e5392 100644 --- a/docs/_static/generate_images.py +++ b/docs/_static/generate_images.py @@ -411,8 +411,7 @@ def logo_scene() -> StructureScene: scene.set_atom_data("gradient", by_index=data) scene.view.look_along(look_dir) - scene.view.perspective = 0.12 - scene.view.view_distance = 5.0 + scene.view.set_perspective(0.12, 5.0) return scene @@ -652,13 +651,13 @@ def generate_docs_images() -> None: figsize=(3, 3), dpi=150, ) print(f" wrote {OUT / 'perovskite_ortho.svg'}") - perov_plain.view.perspective = 0.5 + perov_plain.view.set_perspective(0.5) perov_plain.render_mpl( OUT / "perovskite_perspective.svg", figsize=(3, 3), dpi=150, ) print(f" wrote {OUT / 'perovskite_perspective.svg'}") - perov_plain.view.perspective = 0.0 # Reset + perov_plain.view.set_orthographic() # Reset # 6–9. Per-atom colouring examples: ring of atoms. n = 16 diff --git a/docs/api.rst b/docs/api.rst index 1882dbd7..bab1e917 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -43,6 +43,18 @@ Data model .. autoclass:: ViewState :members: +.. autoclass:: Orthographic + :members: + +.. autoclass:: Perspective + :members: + +.. data:: Projection + + Type alias for the projection modes: + ``Orthographic | Perspective``. Use it to annotate code that + accepts any projection. + .. autoclass:: hofmann.model.atom_data.AtomData :members: n_atoms, ranges, labels diff --git a/docs/changelog.rst b/docs/changelog.rst index a7e46718..01a82cf3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,29 @@ Changelog ========= +Unreleased +---------- + +- **Breaking:** Projection mode is now selected with a single + :attr:`~hofmann.ViewState.projection` field taking + :class:`~hofmann.Orthographic` (the default) or + :class:`~hofmann.Perspective`, set with + :meth:`~hofmann.ViewState.set_orthographic` or + :meth:`~hofmann.ViewState.set_perspective`. The ``perspective`` and + ``view_distance`` attributes are removed; ``perspective = 0.3`` + becomes ``set_perspective(0.3)`` and ``perspective = 0.0`` becomes + ``set_orthographic()``. A ``view_distance`` set alongside a + perspective strength moves into the same call: + ``set_perspective(0.3, 5.0)``. A perspective strength no longer + doubles as an on/off switch, so a viewing distance can no longer be + set on a view that ignores it. + +- In interactive sessions, the ``d`` and ``D`` viewing-distance keys + now act only in perspective mode, where previously they adjusted a + distance that had no effect until perspective was switched on. + ``P`` returns to an orthographic view at the bottom of the ladder, + as before. + 0.20.0 ------ diff --git a/docs/interactive.rst b/docs/interactive.rst index e777fe4d..12ccb7f1 100644 --- a/docs/interactive.rst +++ b/docs/interactive.rst @@ -75,9 +75,10 @@ Pan and perspective * - Shift + Arrow keys - Pan the view * - ``p`` / ``P`` - - Increase / decrease perspective strength + - Increase / decrease perspective strength (``P`` returns to an + orthographic view) * - ``d`` / ``D`` - - Increase / decrease viewing distance + - Increase / decrease viewing distance (perspective only) Display toggles ^^^^^^^^^^^^^^^ diff --git a/docs/rendering.rst b/docs/rendering.rst index 7084ac7a..5423ff9f 100644 --- a/docs/rendering.rst +++ b/docs/rendering.rst @@ -42,19 +42,19 @@ Perspective .. code-block:: python - scene.view.perspective = 0.3 # Mild perspective - scene.view.perspective = 0.0 # Orthographic (default) + scene.view.set_perspective(0.3) # Mild perspective + scene.view.set_orthographic() # Parallel projection (default) .. list-table:: :widths: 50 50 * - .. figure:: _static/perovskite_ortho.svg - Orthographic (``perspective=0.0``) + Orthographic (the default) - .. figure:: _static/perovskite_perspective.svg - Perspective (``perspective=0.5``) + Perspective (``set_perspective(0.5)``) Render styles diff --git a/src/hofmann/__init__.py b/src/hofmann/__init__.py index c9a068bd..0e757757 100644 --- a/src/hofmann/__init__.py +++ b/src/hofmann/__init__.py @@ -32,10 +32,13 @@ Frame, LegendItem, LegendStyle, + Orthographic, + Perspective, Polyhedron, PolygonLegendItem, PolyhedronLegendItem, PolyhedronSpec, + Projection, RenderStyle, SlabClipMode, StructureScene, @@ -61,10 +64,13 @@ "Frame", "LegendItem", "LegendStyle", + "Orthographic", + "Perspective", "Polyhedron", "PolygonLegendItem", "PolyhedronLegendItem", "PolyhedronSpec", + "Projection", "RenderStyle", "SlabClipMode", "StructureScene", diff --git a/src/hofmann/model/__init__.py b/src/hofmann/model/__init__.py index b46b403d..d7e89361 100644 --- a/src/hofmann/model/__init__.py +++ b/src/hofmann/model/__init__.py @@ -30,7 +30,12 @@ _DEFAULT_SPACING, ) from hofmann.model.structure_scene import StructureScene -from hofmann.model.view_state import ViewState +from hofmann.model.view_state import ( + Orthographic, + Perspective, + Projection, + ViewState, +) __all__ = [ "AtomLegendItem", @@ -40,15 +45,18 @@ "BondSpec", "CellEdgeStyle", "CmapSpec", - "LegendItem", - "LegendStyle", "Colour", "Composition", "Frame", - "Polyhedron", + "LegendItem", + "LegendStyle", + "Orthographic", + "Perspective", "PolygonLegendItem", + "Polyhedron", "PolyhedronLegendItem", "PolyhedronSpec", + "Projection", "RenderStyle", "SlabClipMode", "StructureScene", diff --git a/src/hofmann/model/view_state.py b/src/hofmann/model/view_state.py index 9d7797df..69b9cae1 100644 --- a/src/hofmann/model/view_state.py +++ b/src/hofmann/model/view_state.py @@ -1,10 +1,59 @@ from __future__ import annotations +import math +import warnings from dataclasses import dataclass, field +from typing import assert_never import numpy as np +@dataclass(frozen=True, slots=True) +class Orthographic: + """Parallel projection: depth is not foreshortened.""" + + +@dataclass(frozen=True, slots=True) +class Perspective: + """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}" + ) + + +#: 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() + + @dataclass class ViewState: """Camera state for 3D-to-2D projection. @@ -24,8 +73,8 @@ class ViewState: rotation: 3x3 rotation matrix. zoom: Magnification factor. centre: 3D point about which to centre the view. - perspective: Perspective strength (0 = orthographic). - view_distance: Distance from camera to scene centre. + projection: Projection mode, :class:`Orthographic` (the + default) or :class:`Perspective`. slab_origin: 3D point defining the slab reference depth, or ``None`` to use *centre*. slab_near: Near offset from the slab origin depth (negative = @@ -41,8 +90,9 @@ class ViewState: centre: np.ndarray = field( default_factory=lambda: np.zeros(3, dtype=float) ) - perspective: float = 0.0 - view_distance: float = 10.0 + projection: Projection = field( + default_factory=Orthographic + ) slab_origin: np.ndarray | None = None slab_near: float | None = None slab_far: float | None = None @@ -50,18 +100,15 @@ class ViewState: def __post_init__(self) -> None: if self.zoom <= 0: raise ValueError(f"zoom must be positive, got {self.zoom}") - if self.view_distance <= 0: - raise ValueError( - f"view_distance must be positive, got {self.view_distance}" - ) def project( self, coords: np.ndarray, radii: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Project 3D coordinates to 2D with depth information. - The eye sits at ``[0, 0, view_distance]`` and each sphere's - visible silhouette is projected onto the z=0 plane. + Under :class:`Perspective` the eye sits on the camera's +z + axis and each sphere's visible silhouette is projected onto + the z=0 plane. Args: coords: Array of shape ``(n, 3)``. @@ -80,29 +127,117 @@ def project( centred = coords - self.centre rotated = centred @ self.rotation.T depth = rotated[:, 2] + xy, _ = self.project_camera(rotated) - if self.perspective > 0: - # Eye-to-atom distance along z. - d = self.view_distance - depth * self.perspective - scale = self.view_distance / d - xy = rotated[:, :2] * scale[:, np.newaxis] * self.zoom + if radii is None: + return xy, depth, np.zeros(len(depth)) - if radii is not None: - radii = np.asarray(radii, dtype=float) + 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 * self.view_distance / denom * self.zoom - else: - projected_radii = np.zeros(len(depth)) - else: - xy = rotated[:, :2] * self.zoom - if radii is not None: - projected_radii = np.asarray(radii, dtype=float) * self.zoom - else: - projected_radii = np.zeros(len(depth)) + 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]: + """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. + + 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). + """ + 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 + + def set_orthographic(self) -> ViewState: + """Draw without perspective foreshortening. + + Returns: + ``self``, so the call can be chained with + :meth:`look_along`. + """ + self.projection = Orthographic() + return self + + def set_perspective( + self, + strength: float = _DEFAULT_PERSPECTIVE.strength, + view_distance: float = _DEFAULT_PERSPECTIVE.view_distance, + ) -> ViewState: + """Draw with perspective foreshortening. + + Args: + strength: Perspective strength. ``1.0`` is a true pinhole + camera at *view_distance*; smaller values move the eye + further out and weaken the effect. + view_distance: Reference distance from the scene centre. + + Returns: + ``self``, so the call can be chained with + :meth:`look_along`. + """ + self.projection = Perspective(strength, view_distance) + return self + def slab_mask(self, coords: np.ndarray) -> np.ndarray: """Return a boolean mask selecting atoms within the depth slab. diff --git a/src/hofmann/rendering/bond_geometry.py b/src/hofmann/rendering/bond_geometry.py index 94b22912..e4f6c7dc 100644 --- a/src/hofmann/rendering/bond_geometry.py +++ b/src/hofmann/rendering/bond_geometry.py @@ -2,11 +2,39 @@ from __future__ import annotations +from typing import assert_never + import numpy as np -from hofmann.model import ViewState +from hofmann.model import Orthographic, Perspective, 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, @@ -186,10 +214,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). - # For orthographic projection, push the eye to effective infinity - # so all view rays are parallel (matching XBS pmode==0 behaviour). - eye_dist = view.view_distance if view.perspective > 0 else 1e6 - eye = np.array([0.0, 0.0, eye_dist]) + eye = np.array([0.0, 0.0, _foreshortening_distance(view)]) q_a = eye - p_a q_b = eye - p_b denom_a = np.linalg.norm(q_a) * bond_len @@ -331,8 +356,7 @@ def _bond_polygons_batch( valid &= (bond_len_safe - w_a - w_b) > 0 # Foreshortening angles. - eye_dist = view.view_distance if view.perspective > 0 else 1e6 - eye = np.array([0.0, 0.0, eye_dist]) + eye = np.array([0.0, 0.0, _foreshortening_distance(view)]) 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/cell_edges.py b/src/hofmann/rendering/cell_edges.py index c3fc8df1..a60c1945 100644 --- a/src/hofmann/rendering/cell_edges.py +++ b/src/hofmann/rendering/cell_edges.py @@ -300,25 +300,15 @@ def _collect_cell_edges( if boundaries[i + 1] - boundaries[i] > 1e-12 ] - for t0, t1 in sub_fracs: - sub_c_s = c_s + (c_e - c_s) * t0 - sub_c_e = c_s + (c_e - c_s) * t1 - sub_d = (sub_c_s[2] + sub_c_e[2]) / 2.0 - - if view.perspective > 0: - s_s = view.view_distance / ( - view.view_distance - - sub_c_s[2] * view.perspective - ) - s_e = view.view_distance / ( - view.view_distance - - sub_c_e[2] * view.perspective - ) - xy_s_i = sub_c_s[:2] * s_s * view.zoom - xy_e_i = sub_c_e[:2] * s_e * view.zoom - else: - xy_s_i = sub_c_s[:2] * view.zoom - xy_e_i = sub_c_e[:2] * view.zoom + # Project every sub-segment endpoint of this edge in one + # 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_depths = sub_c[:, 2].reshape(-1, 2) + + for (xy_s_i, xy_e_i), (z_s, z_e) in zip(sub_xy, sub_depths): + sub_d = (z_s + z_e) / 2.0 if dash_pattern is not None: dash_segs = _split_dashes( diff --git a/src/hofmann/rendering/interactive.py b/src/hofmann/rendering/interactive.py index cc7d8efb..abf99e1e 100644 --- a/src/hofmann/rendering/interactive.py +++ b/src/hofmann/rendering/interactive.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from dataclasses import replace from typing import Any import matplotlib.pyplot as plt @@ -11,6 +12,8 @@ from hofmann.model import ( CmapSpec, Colour, + Orthographic, + Perspective, RenderStyle, StructureScene, ViewState, @@ -64,12 +67,19 @@ def _rotation_z(angle: float) -> np.ndarray: _KEY_ZOOM_FACTOR = 1.1 # multiplicative zoom per key press / scroll step _KEY_PAN_FRACTION = 0.05 # fraction of scene extent 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 +_MIN_VIEW_DISTANCE = 0.1 # floor on Perspective.view_distance + +#: Seeds the viewing distance the p key restores when no perspective +#: has been used this session. Only view_distance is read back: p sets +#: the strength itself. +_DEFAULT_INTERACTIVE_PERSPECTIVE = Perspective() _HELP_TEXT = """\ Arrows Rotate Shift+Arrows Pan , . Roll + = - Zoom -p P Perspective d D Distance +p P Perspective d D Distance (persp) b Bonds o Outlines e Polyhedra u Unit cell a Axes r Reset view @@ -167,13 +177,55 @@ def _apply_key_action( # -- Perspective / distance -- elif key == "p": - view.perspective = min(1.0, view.perspective + _PERSPECTIVE_STEP) + match view.projection: + case Perspective() as proj: + view.projection = replace( + proj, + strength=min(1.0, proj.strength + _PERSPECTIVE_STEP), + ) + case _: + # Any non-perspective mode is replaced wholesale. + # A parallel projection cannot hold a viewing distance, + # so re-entering perspective restores the one the + # session last used rather than resetting it. + view.projection = replace( + state["last_perspective"], strength=_PERSPECTIVE_STEP, + ) elif key == "P": - view.perspective = max(0.0, view.perspective - _PERSPECTIVE_STEP) + match view.projection: + case Perspective() as proj: + strength = proj.strength - _PERSPECTIVE_STEP + # The floor absorbs the float residue left by repeated + # addition and subtraction, so the descent lands on + # Orthographic rather than stranding on ~1e-17. + if strength > _PERSPECTIVE_FLOOR: + view.projection = replace(proj, strength=strength) + else: + state["last_perspective"] = proj + view.projection = Orthographic() + case _: + # Nothing to step down from under a parallel mode. + return "none" elif key == "d": - view.view_distance *= _DISTANCE_FACTOR + match view.projection: + case Perspective() as proj: + view.projection = replace( + proj, view_distance=proj.view_distance * _DISTANCE_FACTOR, + ) + case _: + return "none" elif key == "D": - view.view_distance = max(0.1, view.view_distance / _DISTANCE_FACTOR) + match view.projection: + case Perspective() as proj: + view.projection = replace( + proj, + view_distance=max( + _MIN_VIEW_DISTANCE, + proj.view_distance / _DISTANCE_FACTOR, + ), + ) + case _: + return "none" # -- Style toggles (no recomputation needed) -- elif key == "b": @@ -230,8 +282,10 @@ def _apply_key_action( view.rotation = initial_view["rotation"].copy() view.zoom = initial_view["zoom"] view.centre = initial_view["centre"].copy() - view.perspective = initial_view["perspective"] - view.view_distance = initial_view["view_distance"] + view.projection = initial_view["projection"] + # Reset means reset: a distance dialled in earlier must not + # survive to be restored by a later p. + state["last_perspective"] = initial_view["last_perspective"] # -- Help overlay -- elif key == "h": @@ -273,7 +327,8 @@ def render_mpl_interactive( - **+** / **=** / **-** zoom in/out. - **Shift+Arrow** keys pan the view. - **p** / **P** increase/decrease perspective strength. - - **d** / **D** increase/decrease viewing distance. + - **d** / **D** increase/decrease viewing distance + (perspective mode only). - **b** toggle bonds, **o** toggle outlines, **e** toggle polyhedra, **u** toggle unit cell, **a** toggle axes widget. - **[** / **]** step to the previous/next frame; @@ -338,8 +393,7 @@ def render_mpl_interactive( rotation=scene.view.rotation.copy(), zoom=scene.view.zoom, centre=scene.view.centre.copy(), - perspective=scene.view.perspective, - view_distance=scene.view.view_distance, + projection=scene.view.projection, slab_origin=( scene.view.slab_origin.copy() if scene.view.slab_origin is not None else None @@ -388,6 +442,13 @@ def render_mpl_interactive( "input_mode": None, "input_buffer": "", "indicator_visible": False, + # Carries the viewing distance across an orthographic + # excursion, which cannot represent one. + "last_perspective": ( + scene.view.projection + if isinstance(scene.view.projection, Perspective) + else _DEFAULT_INTERACTIVE_PERSPECTIVE + ), } # Snapshot for the reset key. @@ -395,8 +456,8 @@ def render_mpl_interactive( "rotation": view.rotation.copy(), "zoom": view.zoom, "centre": view.centre.copy(), - "perspective": view.perspective, - "view_distance": view.view_distance, + "projection": view.projection, + "last_perspective": state["last_perspective"], } _DRAG_SENSITIVITY = 0.01 # radians per pixel diff --git a/src/hofmann/rendering/projection.py b/src/hofmann/rendering/projection.py index 879c7fbf..da6a83f1 100644 --- a/src/hofmann/rendering/projection.py +++ b/src/hofmann/rendering/projection.py @@ -2,9 +2,17 @@ from __future__ import annotations +import warnings +from typing import assert_never + import numpy as np -from hofmann.model import StructureScene, ViewState +from hofmann.model import ( + Orthographic, + Perspective, + StructureScene, + ViewState, +) from hofmann.model.composition import Composition, _OCCUPANCY_TOLERANCE from hofmann.rendering.precompute import _compute_atom_radii @@ -40,13 +48,8 @@ def _project_point( Tuple of (xy, scale) where *xy* is the 2D position and *scale* is the perspective scale factor at this depth. """ - z = pt[2] - if view.perspective > 0: - s = view.view_distance / (view.view_distance - z * view.perspective) - else: - s = 1.0 - xy = pt[:2] * s * view.zoom - return xy, s + xy, scale = view.project_camera(np.asarray(pt, dtype=float)[np.newaxis]) + return xy[0], float(scale[0]) # Fractional coordinates of the 8 unit cube corners. @@ -99,14 +102,30 @@ 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). - if view.perspective > 0 and len(dists) > 0: - worst_depth = float(np.max(dists)) - denom = view.view_distance - worst_depth * view.perspective - if denom > 0: - persp_scale = view.view_distance / denom - else: - persp_scale = view.view_distance / 1e-6 - max_extent *= persp_scale + 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) return float(max_extent * view.zoom) diff --git a/tests/test_model/test_view_state.py b/tests/test_model/test_view_state.py index 26a2c400..eefca722 100644 --- a/tests/test_model/test_view_state.py +++ b/tests/test_model/test_view_state.py @@ -1,9 +1,12 @@ """Tests for ViewState projection, look_along, slab clipping, and validation.""" +import dataclasses +import warnings + import numpy as np import pytest -from hofmann.model.view_state import ViewState +from hofmann.model.view_state import Orthographic, Perspective, ViewState class TestViewStateProject: @@ -42,7 +45,7 @@ def test_90_degree_z_rotation(self): np.testing.assert_allclose(xy, [[0.0, 1.0]], atol=1e-10) def test_perspective_scaling(self): - vs = ViewState(perspective=1.0, view_distance=10.0) + vs = ViewState(projection=Perspective(1.0, 10.0)) coords = np.array([ [1.0, 0.0, 0.0], [1.0, 0.0, -5.0], @@ -69,7 +72,7 @@ def test_projected_radii_orthographic(self): np.testing.assert_allclose(proj_r, [3.0]) # r * zoom def test_projected_radii_perspective(self): - vs = ViewState(perspective=1.0, view_distance=10.0) + vs = ViewState(projection=Perspective(1.0, 10.0)) coords = np.array([[0.0, 0.0, 0.0]]) radii = np.array([1.0]) _, _, proj_r = vs.project(coords, radii) @@ -77,9 +80,39 @@ def test_projected_radii_perspective(self): expected = 10.0 / np.sqrt(99.0) np.testing.assert_allclose(proj_r, [expected], rtol=1e-6) + def test_zoom_scales_perspective_positions(self): + """Zoom must apply under perspective, not only orthographic.""" + coords = np.array([[1.0, 2.0, 3.0]]) + plain = ViewState(projection=Perspective(0.7, 12.0)) + zoomed = ViewState(zoom=2.5, projection=Perspective(0.7, 12.0)) + xy_plain, _, _ = plain.project(coords) + xy_zoomed, _, _ = zoomed.project(coords) + np.testing.assert_allclose(xy_zoomed, xy_plain * 2.5) + + def test_zoom_scales_perspective_radii(self): + coords = np.array([[0.0, 0.0, 1.0]]) + radii = np.array([0.8]) + plain = ViewState(projection=Perspective(0.7, 12.0)) + zoomed = ViewState(zoom=2.5, projection=Perspective(0.7, 12.0)) + _, _, r_plain = plain.project(coords, radii) + _, _, r_zoomed = zoomed.project(coords, radii) + np.testing.assert_allclose(r_zoomed, r_plain * 2.5) + + def test_points_at_or_behind_the_eye_plane_warn(self): + """Behind-eye points draw mirrored and sort frontmost.""" + vs = ViewState(projection=Perspective(1.0, 10.0)) + with pytest.warns(UserWarning, match="eye plane"): + vs.project(np.array([[1.0, 0.0, 11.0]])) + + def test_no_eye_plane_warning_for_ordinary_depths(self): + vs = ViewState(projection=Perspective(1.0, 10.0)) + with warnings.catch_warnings(): + warnings.simplefilter("error") + vs.project(np.array([[1.0, 0.0, 2.0]])) + def test_projected_radii_larger_than_point_scale(self): """Silhouette radii should exceed naive r * scale under perspective.""" - vs = ViewState(perspective=1.0, view_distance=10.0) + vs = ViewState(projection=Perspective(1.0, 10.0)) coords = np.array([[0.0, 0.0, 2.0]]) # closer to eye radii = np.array([1.0]) _, _, proj_r = vs.project(coords, radii) @@ -148,11 +181,10 @@ def test_custom_up_vector(self): def test_preserves_other_state(self): """look_along should only change the rotation.""" - vs = ViewState(zoom=2.5, perspective=0.8, view_distance=15.0) + vs = ViewState(zoom=2.5, projection=Perspective(0.8, 15.0)) vs.look_along([1, 1, 0]) assert vs.zoom == 2.5 - assert vs.perspective == 0.8 - assert vs.view_distance == 15.0 + assert vs.projection == Perspective(0.8, 15.0) def test_up_parallel_to_direction_raises(self): """An explicit up vector parallel to the view direction should raise.""" @@ -282,14 +314,77 @@ def test_negative_zoom_raises(self): with pytest.raises(ValueError, match="zoom"): ViewState(zoom=-1.0) - def test_zero_view_distance_raises(self): - with pytest.raises(ValueError, match="view_distance"): - ViewState(view_distance=0.0) - - def test_negative_view_distance_raises(self): - with pytest.raises(ValueError, match="view_distance"): - ViewState(view_distance=-1.0) - def test_valid_view_state_accepted(self): - vs = ViewState(zoom=2.0, view_distance=15.0) + vs = ViewState(zoom=2.0, projection=Perspective(0.5, 15.0)) assert vs.zoom == 2.0 + assert vs.projection.view_distance == 15.0 + + def test_projection_defaults_to_orthographic(self): + assert ViewState().projection == Orthographic() + + +class TestProjectionTypes: + def test_perspective_defaults(self): + p = Perspective() + assert p.strength == 0.5 + assert p.view_distance == 10.0 + + @pytest.mark.parametrize( + "strength", [-0.5, float("nan"), float("inf")], + ) + def test_invalid_strength_rejected(self, strength): + with pytest.raises(ValueError, match="strength"): + Perspective(strength=strength) + + @pytest.mark.parametrize( + "view_distance", [0.0, -1.0, float("nan"), float("inf")], + ) + def test_invalid_view_distance_rejected(self, view_distance): + with pytest.raises(ValueError, match="view_distance"): + Perspective(view_distance=view_distance) + + def test_zero_strength_directs_the_caller_to_orthographic(self): + """A parallel projection is a different type, not a zero strength.""" + with pytest.raises(ValueError, match="Orthographic"): + Perspective(strength=0.0) + + def test_modes_are_frozen(self): + with pytest.raises(dataclasses.FrozenInstanceError): + Perspective().strength = 0.9 + + def test_modes_reject_unknown_attributes(self): + """slots keeps a typo loud rather than setting an inert attribute. + + The exception type is a CPython detail that moved: up to 3.12 + the slots check fires first and raises ``TypeError``, from 3.13 + the frozen check fires first and raises + ``FrozenInstanceError``. What matters here is that the write + is refused and leaves nothing behind, so both are accepted. + """ + mode = Orthographic() + with pytest.raises((AttributeError, TypeError)): + mode.anything = 1 + assert not hasattr(mode, "anything") + # frozen alone would satisfy the raise above, so pin the slots + # that make an unknown attribute unstorable in the first place. + assert not hasattr(mode, "__dict__") + assert not hasattr(Perspective(), "__dict__") + + def test_setters_return_self_for_chaining(self): + vs = ViewState() + assert vs.set_perspective() is vs + assert vs.set_orthographic() is vs + + def test_setters_select_the_mode(self): + vs = ViewState().set_perspective(0.8, 15.0) + assert vs.projection == Perspective(0.8, 15.0) + assert vs.set_orthographic().projection == Orthographic() + + def test_setter_validates_through_the_type(self): + with pytest.raises(ValueError, match="strength"): + ViewState().set_perspective(strength=-1.0) + + 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() diff --git a/tests/test_rendering/test_interactive.py b/tests/test_rendering/test_interactive.py index 7ffcc5ad..aefe2252 100644 --- a/tests/test_rendering/test_interactive.py +++ b/tests/test_rendering/test_interactive.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from hofmann.model import RenderStyle, ViewState +from hofmann.model import Orthographic, Perspective, RenderStyle, ViewState from hofmann.rendering.interactive import ( _apply_key_action, _HELP_TEXT, @@ -78,13 +78,14 @@ def _key_action_fixtures(): "indicator_visible": False, "input_mode": None, "input_buffer": "", + "last_perspective": Perspective(strength=0.1), } initial_view = { "rotation": view.rotation.copy(), "zoom": view.zoom, "centre": view.centre.copy(), - "perspective": view.perspective, - "view_distance": view.view_distance, + "projection": view.projection, + "last_perspective": state["last_perspective"], } return view, style, state, initial_view @@ -205,50 +206,127 @@ def test_pan_down(self): def test_perspective_increase(self): view, style, state, iv = _key_action_fixtures() kind = _do_key("p", view, style, state, iv) - assert view.perspective == pytest.approx(_PERSPECTIVE_STEP) + assert view.projection.strength == pytest.approx(_PERSPECTIVE_STEP) assert kind == "view" def test_perspective_decrease(self): view, style, state, iv = _key_action_fixtures() - view.perspective = 0.5 + view.projection = Perspective(0.5) _do_key("P", view, style, state, iv) - assert view.perspective == pytest.approx(0.5 - _PERSPECTIVE_STEP) + assert view.projection.strength == pytest.approx( + 0.5 - _PERSPECTIVE_STEP + ) def test_perspective_clamped_max(self): view, style, state, iv = _key_action_fixtures() - view.perspective = 0.95 + view.projection = Perspective(0.95) _do_key("p", view, style, state, iv) - assert view.perspective == 1.0 + assert view.projection.strength == 1.0 - def test_perspective_clamped_min(self): + def test_perspective_steps_out_to_orthographic(self): + """The last step down lands on Orthographic, not a tiny strength.""" view, style, state, iv = _key_action_fixtures() - view.perspective = 0.05 + view.projection = Perspective(0.05) _do_key("P", view, style, state, iv) - assert view.perspective == 0.0 + assert view.projection == Orthographic() + + def test_perspective_descent_reaches_orthographic_exactly(self): + """Float residue must not strand the descent just above zero. + + 0.4 is chosen deliberately: repeated subtraction leaves a + positive residue (~+2.8e-17), which a bare ``> 0.0`` test would + keep as a Perspective. 0.3 leaves a negative residue and so + would pass either way. + """ + view, style, state, iv = _key_action_fixtures() + view.projection = Perspective(0.4) + for _ in range(4): + _do_key("P", view, style, state, iv) + assert view.projection == Orthographic() + + def test_reset_clears_the_stashed_viewing_distance(self): + """Reset means reset: p must not restore a pre-reset distance.""" + view, style, state, iv = _key_action_fixtures() + view.projection = Perspective(0.3, 25.0) + for _ in range(3): + _do_key("P", view, style, state, iv) + _do_key("r", view, style, state, iv) + _do_key("p", view, style, state, iv) + assert view.projection.view_distance == ( + iv["last_perspective"].view_distance + ) + + @pytest.mark.parametrize("start", [0.37, 0.05, 0.999]) + def test_ladder_descends_from_any_programmatic_strength(self, start): + """set_perspective accepts any strength, so the ladder must + step by a fixed amount from wherever it starts, keeping the + caller's fractional part, and exit to Orthographic when the + next step would leave nothing. + """ + view, style, state, iv = _key_action_fixtures() + view.projection = Perspective(start, 25.0) + state["last_perspective"] = view.projection + + seen = [start] + for _ in range(15): + _do_key("P", view, style, state, iv) + if isinstance(view.projection, Orthographic): + break + seen.append(view.projection.strength) + else: + raise AssertionError("descent never reached Orthographic") + + # Each step is exactly one increment. + for before, after in zip(seen, seen[1:]): + assert before - after == pytest.approx(_PERSPECTIVE_STEP) + + def test_view_distance_survives_an_orthographic_excursion(self): + """A parallel projection cannot hold a distance; the session can.""" + view, style, state, iv = _key_action_fixtures() + view.projection = Perspective(0.3, 25.0) + state["last_perspective"] = view.projection + for _ in range(3): + _do_key("P", view, style, state, iv) + assert view.projection == Orthographic() + _do_key("p", view, style, state, iv) + assert view.projection.view_distance == 25.0 # -- Distance -- def test_distance_increase(self): view, style, state, iv = _key_action_fixtures() - old = view.view_distance + view.projection = Perspective(0.7, 20.0) + old = view.projection.view_distance kind = _do_key("d", view, style, state, iv) - assert view.view_distance == pytest.approx(old * 1.05) + assert view.projection.view_distance == pytest.approx(old * 1.05) assert kind == "view" def test_distance_decrease(self): view, style, state, iv = _key_action_fixtures() - old = view.view_distance + view.projection = Perspective(0.7, 20.0) + old = view.projection.view_distance _do_key("D", view, style, state, iv) - assert view.view_distance == pytest.approx(old / 1.05) + assert view.projection.view_distance == pytest.approx(old / 1.05) def test_distance_clamped_min(self): view, style, state, iv = _key_action_fixtures() - view.view_distance = 0.11 - _do_key("D", view, style, state, iv) - # 0.11 / 1.05 ~ 0.1048, still above 0.1 - _do_key("D", view, style, state, iv) - _do_key("D", view, style, state, iv) - assert view.view_distance >= 0.1 + view.projection = Perspective(0.5, 0.11) + # 0.11 / 1.05 ~ 0.1048, still above the floor + for _ in range(3): + _do_key("D", view, style, state, iv) + assert view.projection.view_distance >= 0.1 + + @pytest.mark.parametrize("key", ["P", "d", "D"]) + def test_perspective_keys_are_inert_without_perspective(self, key): + """Only p enters perspective mode; the rest need it already set. + + The redraw kind is asserted too: a key that changes nothing + must not ask for a redraw, or the no-op costs a frame. + """ + view, style, state, iv = _key_action_fixtures() + kind = _do_key(key, view, style, state, iv) + assert view.projection == Orthographic() + assert kind == "none" # -- Style toggles -- @@ -398,14 +476,12 @@ def test_reset_restores_initial_view(self): view.rotation = _rotation_y(1.0) @ view.rotation view.zoom = 3.5 view.centre = np.array([1.0, 2.0, 3.0]) - view.perspective = 0.7 - view.view_distance = 20.0 + view.projection = Perspective(0.7, 20.0) kind = _do_key("r", view, style, state, iv) np.testing.assert_allclose(view.rotation, np.eye(3)) assert view.zoom == 1.0 np.testing.assert_allclose(view.centre, [0.0, 0.0, 0.0]) - assert view.perspective == 0.0 - assert view.view_distance == 10.0 + assert view.projection == Orthographic() assert kind == "view" # -- Help overlay -- diff --git a/tests/test_rendering/test_projection.py b/tests/test_rendering/test_projection.py index e2c1e556..cbf9deb4 100644 --- a/tests/test_rendering/test_projection.py +++ b/tests/test_rendering/test_projection.py @@ -1,17 +1,29 @@ """Tests for projection helpers — _project_point and _scene_extent.""" import math +import warnings +import matplotlib.pyplot as plt import numpy as np +import pytest -from hofmann.model import AtomStyle, Frame, StructureScene, ViewState +from hofmann.model import ( + AtomStyle, + Frame, + Orthographic, + Perspective, + StructureScene, + ViewState, +) from hofmann.model.composition import Composition +from hofmann.rendering.cell_edges import _cell_edges_3d from hofmann.rendering.projection import ( _make_vacancy_wedge, _make_wedges, _project_point, _scene_extent, ) +from hofmann.rendering.static import render_mpl class TestProjectPoint: @@ -23,7 +35,7 @@ def test_orthographic(self): assert s == 1.0 def test_perspective(self): - view = ViewState(perspective=1.0, view_distance=10.0) + 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) @@ -44,12 +56,37 @@ def test_perspective_increases_extent(self): ]))], atom_styles={"C": AtomStyle(1.0, (0.5, 0.5, 0.5))}, ) - view_no_persp = ViewState(perspective=0.0) - view_persp = ViewState(perspective=0.5) + view_no_persp = ViewState(projection=Orthographic()) + view_persp = ViewState(projection=Perspective(0.5)) e_no = _scene_extent(scene, view_no_persp, 0, atom_scale=0.5) 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( @@ -176,3 +213,172 @@ def test_mixed_partial_composition_returns_polygon(self): comp, n_segments_total=24, start_angle=math.pi / 2, ) assert result is not None + + +def _point_to_segment_distance( + point: np.ndarray, start: np.ndarray, end: np.ndarray, +) -> float: + """Shortest distance from *point* to the segment *start*-*end*.""" + seg = end - start + length_sq = float(seg @ seg) + if length_sq == 0.0: + return float(np.linalg.norm(point - start)) + t = float(np.clip((point - start) @ seg / length_sq, 0.0, 1.0)) + return float(np.linalg.norm(point - (start + t * seg))) + + +class TestDrawnGeometryMatchesProjection: + """Every renderer must obtain screen positions from ViewState. + + The perspective scale was once open-coded in the cell-edge loop + and in _project_point as well as in ViewState.project. These + tests pin the agreement, so a copy reintroduced in either place + fails rather than drifting silently. + """ + + @staticmethod + def _scene() -> StructureScene: + """Two tiny atoms in a cell, so edge clipping at spheres is inert.""" + lattice = np.array([ + [4.0, 0.0, 0.0], [0.5, 3.6, 0.0], [0.3, 0.4, 5.2], + ]) + return StructureScene( + species=["A", "B"], + frames=[Frame( + coords=np.array([[1.0, 1.0, 1.0], [2.4, 1.8, 3.0]]), + lattice=lattice, + )], + atom_styles={ + "A": AtomStyle(0.01, (0.5, 0.5, 0.5)), + "B": AtomStyle(0.01, (0.8, 0.2, 0.2)), + }, + ) + + @staticmethod + def _drawn_edge_endpoints(fig) -> np.ndarray: + """Recover segment endpoints from drawn cell-edge rectangles. + + Edges are drawn as [start+offset, end+offset, end-offset, + start-offset], so the midpoints of the short sides recover the + endpoints exactly -- the half-width offsets cancel. With atom + radii this small, 4-vertex polygons are only ever cell edges. + """ + endpoints = [] + for collection in fig.axes[0].collections: + for path in collection.get_paths(): + v = np.asarray(path.vertices) + if len(v) in (4, 5): # 5 = closed polygon repeats vertex 0 + q = v[:4] + endpoints.append((q[0] + q[3]) / 2) + endpoints.append((q[1] + q[2]) / 2) + return np.array(endpoints) + + @pytest.mark.parametrize( + "projection", + [Orthographic(), Perspective(0.6, 12.0)], + ids=["orthographic", "perspective"], + ) + def test_cell_edges_agree_with_view_project(self, projection): + scene = self._scene() + scene.view = ViewState(projection=projection) + scene.view.look_along([1.0, 0.6, 0.4]) + + fig = render_mpl(scene, show=False) + drawn = self._drawn_edge_endpoints(fig) + plt.close(fig) + + # The renderer's own edge set, so the test cannot drift from + # the geometry it checks. Edges are subdivided for depth + # sorting, so a drawn endpoint is generally interior to an + # edge -- but a projection maps straight lines to straight + # lines, so it must still lie on the projected edge. + starts, ends = _cell_edges_3d(scene.frames[0].lattice) + projected, _, _ = scene.view.project(np.vstack([starts, ends])) + edges = list(zip(projected[: len(starts)], projected[len(starts):])) + + assert len(drawn) > 0 + for point in drawn: + gap = min( + _point_to_segment_distance(point, start, end) + for start, end in edges + ) + assert gap < 1e-9, f"drawn endpoint {point} lies off every edge" + + 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) + for i, point in enumerate(camera): + xy, scale = _project_point(point, view) + np.testing.assert_array_equal(xy, batch_xy[i]) + assert scale == batch_scale[i] + + +class TestCellEdgeSubSegmentPairing: + """Sub-segment screen positions must stay paired with their depths. + + Edges are split at atom depths and each piece is filed in the depth + slot its own midpoint falls into, so atoms occlude the pieces + behind them and not the ones in front. Projecting a whole edge's + endpoints in one call makes it possible to pair a piece with + another piece's depth; the point-on-segment check above cannot see + that, being invariant under permuting the pieces. + """ + + def test_piece_depth_matches_its_own_screen_position(self): + """Recover each piece's depth from its geometry, independently.""" + from hofmann.model import CellEdgeStyle + from hofmann.rendering.cell_edges import ( + _cell_edges_3d, + _collect_cell_edges, + ) + + lattice = np.eye(3) * 12.0 + # Orthographic: screen position maps back to camera x/y exactly, + # so a piece's depth can be recovered from where it was drawn. + view = ViewState() + view.look_along([1.0, 0.6, 0.4]) + g = np.arange(3) * 5.0 + xs, ys, zs = np.meshgrid(g, g, g, indexing="ij") + coords = np.column_stack([xs.ravel(), ys.ravel(), zs.ravel()]) + depth = coords @ view.rotation[2] + + by_slot = _collect_cell_edges( + lattice=lattice, view=view, cell_style=CellEdgeStyle(), + depth=depth, order=np.argsort(depth), pad=30.0, coords=coords, + radii_3d=np.full(len(coords), 0.4), + ) + assert by_slot, "expected cell edges to be drawn" + + # Camera-space edges, against which a drawn midpoint is located. + starts, ends = _cell_edges_3d(lattice) + cam_s = (starts - view.centre) @ view.rotation.T + cam_e = (ends - view.centre) @ view.rotation.T + + checked = 0 + for pieces in by_slot.values(): + for polygon, _colour, piece_depth in pieces: + mid_xy = np.asarray(polygon).mean(axis=0) / view.zoom + # Find the edge this piece lies on, and how far along. + best = None + for c_s, c_e in zip(cam_s, cam_e): + seg = (c_e - c_s)[:2] + L = float(seg @ seg) + if L == 0.0: + continue + t = float(np.clip((mid_xy - c_s[:2]) @ seg / L, 0.0, 1.0)) + gap = float(np.linalg.norm(c_s[:2] + t * seg - mid_xy)) + if best is None or gap < best[0]: + best = (gap, c_s, c_e, t) + gap, c_s, c_e, t = best + assert gap < 1e-9, "piece does not lie on any cell edge" + # Depth varies linearly along a straight camera-space + # edge, so the piece's own position fixes its depth. + expected = c_s[2] + t * (c_e[2] - c_s[2]) + assert abs(expected - piece_depth) < 1e-9, ( + f"piece drawn at t={t:.4f} along its edge has depth " + f"{expected:.6f}, but was filed under {piece_depth:.6f}" + ) + checked += 1 + assert checked > 12, f"only {checked} pieces checked"