"""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,
)