Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions src/hofmann/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions src/hofmann/model/projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""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
# (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]

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:
# 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:
return bool(np.any(self.view_distance - depth * self.strength <= 0))
132 changes: 23 additions & 109 deletions src/hofmann/model/view_state.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,13 @@
from __future__ import annotations

import math
import warnings
from dataclasses import dataclass, field
from typing import assert_never

import numpy as np

from hofmann.model.projection import Orthographic, Perspective, Projection

@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()


Expand Down Expand Up @@ -127,86 +84,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.
Expand Down
Loading
Loading