Source code for fieldview.camera

"""Direct camera/navigation helpers for interactive or batch FieldView sessions.

The :mod:`fieldview.camera` module complements :mod:`fieldview.view`:

- ``fieldview.view`` handles coarse view operations such as fit, center, and
  perspective enable/disable.
- ``fieldview.camera`` handles direct navigation and deterministic camera
  capture/restore.

All functions default to the current graphics window and accept ``window=``
when a specific pane must be targeted directly.

Use this module when automation needs reproducible camera moves, explicit
camera targeting, or save-and-restore behavior across multiple layout panes.

Example:

.. code-block:: python

    >>> import os
    >>> import fieldview as fv
    >>> data_dir = os.path.join(fv.home, "examples", "f18")
    >>> ds = fv.data.load_plot3d(
    ...     os.path.join(data_dir, "f18i9b_g_bin"),
    ...     os.path.join(data_dir, "f18i9b_q_bin"),
    ... )
    >>> fv.view.fit()
    >>> fv.camera.orbit(15.0, -10.0)
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import cast

from ._core_utils import _core_call, _normalize_window, _require_core_func
from .data import CameraPose, ViewState, _build_camera_pose, _build_view_state
from .exceptions import InvalidArgumentError


def _normalize_scalar(name: str, value: object) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise InvalidArgumentError(f"{name} must be numeric")
    try:
        return float(value)
    except (TypeError, ValueError) as exc:
        raise InvalidArgumentError(f"{name} must be numeric") from exc


def _normalize_vec3(name: str, value: object) -> tuple[float, float, float]:
    if isinstance(value, (str, bytes, bytearray)):
        raise InvalidArgumentError(f"{name} must be a sequence of three numbers")
    if not isinstance(value, Sequence):
        raise InvalidArgumentError(f"{name} must be a sequence of three numbers")
    values = tuple(value)
    if len(values) != 3:
        raise InvalidArgumentError(f"{name} must contain exactly three numeric values")
    return (
        _normalize_scalar(f"{name}[0]", values[0]),
        _normalize_scalar(f"{name}[1]", values[1]),
        _normalize_scalar(f"{name}[2]", values[2]),
    )


[docs] def pan(dx: float, dy: float, *, window: int | None = None) -> None: """Translate the camera laterally in screen/view space. Positive ``dx`` moves right. Positive ``dy`` moves upward. The target and eye move together, so the viewing direction is preserved. Args: dx: Horizontal screen-space pan amount. dy: Vertical screen-space pan amount. window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> fv.camera.pan(0.1, 0.05) """ func = _require_core_func("camera_pan") _core_call( func, _normalize_scalar("dx", dx), _normalize_scalar("dy", dy), _normalize_window(window), )
[docs] def zoom(factor: float, *, window: int | None = None) -> None: """Adjust the current zoom or perspective magnification by a factor. ``factor`` must be greater than zero. Values larger than ``1`` zoom in and values between ``0`` and ``1`` zoom out. Args: factor: Multiplicative zoom factor. Values greater than ``1`` zoom in; values between ``0`` and ``1`` zoom out. window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> fv.camera.zoom(1.2) """ factor = _normalize_scalar("factor", factor) if factor <= 0.0: raise InvalidArgumentError("factor must be greater than zero") func = _require_core_func("camera_zoom") _core_call(func, factor, _normalize_window(window))
[docs] def dolly(distance: float, *, window: int | None = None) -> None: """Move the camera forward or backward along its current view direction. Positive values move the camera closer to the current target. Negative values move it farther away. Args: distance: Signed distance to move along the current view direction. Positive values move toward the target; negative values move away. window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> fv.camera.dolly(0.5) """ func = _require_core_func("camera_dolly") _core_call(func, _normalize_scalar("distance", distance), _normalize_window(window))
[docs] def rotate( yaw: float, pitch: float, roll: float = 0.0, *, window: int | None = None, ) -> None: """Rotate the camera orientation using yaw, pitch, and optional roll. ``yaw`` rotates about the current up axis, ``pitch`` rotates about the current right axis, and ``roll`` rotates about the current forward axis. Args: yaw: Rotation in degrees about the current up axis. pitch: Rotation in degrees about the current right axis. roll: Rotation in degrees about the current forward axis. window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> fv.camera.rotate(10.0, -5.0, 0.0) """ func = _require_core_func("camera_rotate") _core_call( func, _normalize_scalar("yaw", yaw), _normalize_scalar("pitch", pitch), _normalize_scalar("roll", roll), _normalize_window(window), )
[docs] def orbit(dx: float, dy: float, *, window: int | None = None) -> None: """Convenience wrapper around :func:`rotate` for orbit-style navigation. ``dx`` is the yaw rotation in degrees about the current up axis. ``dy`` is the pitch rotation in degrees about the current right axis. This is equivalent to ``rotate(dx, dy, 0.0, window=window)``. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> fv.camera.orbit(15.0, -10.0) """ rotate(dx, dy, 0.0, window=window)
[docs] def look_at( target: object, eye: object = None, up: object = None, *, window: int | None = None, ) -> None: """Aim the camera at a target, with optional explicit eye and up vectors. When ``eye`` or ``up`` is omitted, the current value for that component is used as the input to the look-at solve. The resulting camera frame is orthonormalized by the host, so the final ``up`` direction may be adjusted to remain perpendicular to the final view direction. Args: target: Three-number target position to look at. eye: Optional three-number camera position. When omitted, the current eye position is reused as the look-at input. up: Optional three-number up vector. When omitted, the current up vector is reused as the look-at input and may be adjusted to produce a valid orientation. window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> fv.camera.look_at((0.0, 0.0, 0.0)) """ func = _require_core_func("camera_look_at") normalized_target = _normalize_vec3("target", target) normalized_eye = None if eye is None else _normalize_vec3("eye", eye) normalized_up = None if up is None else _normalize_vec3("up", up) _core_call( func, normalized_target, normalized_eye, normalized_up, _normalize_window(window), )
def get_pose(*, window: int | None = None) -> CameraPose: """Capture the current camera pose as a human-readable summary. The returned pose contains ``eye``, ``target``, ``up``, and perspective settings. It is useful for inspection and approximate camera workflows, but it is not the authoritative deterministic replay format. Use this with ``look_at()`` when you want pose-style camera control. For exact, lossless view replay, use :func:`get_state` and :func:`set_state` instead. Args: window: Optional 1-based window number. When omitted, the current graphics window is used. """ func = _require_core_func("camera_get_state") return _build_camera_pose( cast(dict[str, object], _core_call(func, _normalize_window(window))) )
[docs] def get_state(*, window: int | None = None) -> ViewState: """Capture the current deterministic view state for exact reuse. Args: window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> state = fv.camera.get_state() """ func = _require_core_func("camera_get_state") return _build_view_state( cast(dict[str, object], _core_call(func, _normalize_window(window))) )
[docs] def set_state(state: ViewState, *, window: int | None = None) -> None: """Restore a deterministic view state previously returned by :func:`get_state`. Args: state: Deterministic view state object previously returned by :func:`get_state`. window: Optional 1-based window number. When omitted, the current graphics window is used. Example: .. code-block:: python >>> import os >>> import fieldview as fv >>> data_dir = os.path.join(fv.home, "examples", "f18") >>> ds = fv.data.load_plot3d( ... os.path.join(data_dir, "f18i9b_g_bin"), ... os.path.join(data_dir, "f18i9b_q_bin"), ... ) >>> state = fv.camera.get_state() >>> fv.camera.orbit(20.0, -10.0) >>> fv.camera.set_state(state) """ if not isinstance(state, ViewState): raise InvalidArgumentError("state must be a ViewState") func = _require_core_func("camera_set_state") exact_payload = state.to_exact_state().to_payload() _core_call( func, (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), state.perspective_enabled, _normalize_scalar("perspective_angle", state.perspective_angle), _normalize_window(window), exact_payload, )