Source code for petres.viewers.viewer3d.pyvista.theme

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from .._core.theme import Base3DViewerTheme

# CameraView = Literal[
#     "iso",      # 3D angled view
#     "top",      # map view
#     "bottom",
#     "front",    # look from +Y
#     "back",     # look from -Y
#     "right",    # look from +X
#     "left",     # look from -X
# ]

# @dataclass(frozen=True)
# class Camera3D:
#     """Define camera orientation controls for 3D scene rendering.

#     Parameters
#     ----------
#     view : {"iso", "top", "bottom", "front", "back", "right", "left"}, default="iso"
#         Base camera viewpoint preset.
#     tilt : float, default=0.0
#         Vertical tilt in degrees. Positive values reveal more top surface.
#     turn : float, default=0.0
#         Horizontal rotation in degrees around the vertical axis.
#     roll : float, default=0.0
#         Screen-space roll in degrees.
#     zoom : float, default=1.0
#         Zoom factor where ``1.0`` is neutral, values above ``1.0`` zoom in,
#         and values below ``1.0`` zoom out.
#     position : tuple[float, float, float], default=(0, 0, -1)
#         Absolute camera position in 3D space. Default looks towards the origin from the negative Z direction.
#     """

#     view: CameraView = "iso"

#     # “small intuitive knobs”
#     tilt: float = 0.0     # degrees: + = see more top, - = see more bottom
#     turn: float = 0.0     # degrees: rotate around vertical axis
#     roll: float = 0.0     # degrees: rotate the screen (usually keep 0)
#     zoom: float = 1.0     # 1.0 = normal, 1.2 = closer, 0.8 = farther
#     # focal_point = (0, 0, 0)
#     # up = (0, 1, 0)

Color = str | tuple[float, float, float] 

[docs] @dataclass(frozen=True) class PyVista3DViewerTheme(Base3DViewerTheme): """Configure visual theme and camera settings for a 3D scene. Parameters ---------- background : str | tuple[float, float, float], default="white" Scene background color. show_orientation_widget : bool, default=True Display the orientation widget in the viewer. show_coordinate_axes : bool, default=True Display coordinate axes in the scene. lighting : bool, default=True Enable lighting when adding shaded meshes to the scene. scale : tuple[float, float, float], default=(1.0, 1.0, 1.0) Per-axis scale multipliers for rendering. title_fontsize : int, default=12 Font size for the scene title. title_color : str | tuple[float, float, float], default="black" Title text color. title_position : str, default="upper_edge" Viewer-specific anchor position for title placement. camera : Camera3D, default=Camera3D() Camera configuration applied to the scene. allow_empty_mesh : bool, default=True Allow rendering of meshes with no faces. """ # Core theme parameters # ===================== background: Color = "white" show_orientation_widget: bool = True show_coordinate_axes: bool = True lighting: bool = True # show_grid: bool = True # camera_up: tuple[float, float, float] = (1, 1, -1) scale: tuple[float, float, float] = (1.0, 1.0, 1.0) direction: tuple[float, float, float] = (1, 1, -1) title_fontsize: int = 12 title_color: Color = "black" title_position: str = "upper_edge" # Additional theme parameters # =========================== allow_empty_mesh: bool = True def __post_init__(self): self._validate_scale(self.scale) self._validate_direction(self.direction) @classmethod def _validate_scale(cls, scale): if not isinstance(scale, (tuple, list)): raise TypeError( "`scale` must be a tuple or list of 3 numeric values." ) if len(scale) != 3: raise ValueError( "`scale` must contain exactly 3 values (x_scale, y_scale, z_scale)." ) if not all(isinstance(v, (int, float)) for v in scale): raise TypeError( "All `scale` values must be numeric." ) if any(v == 0 for v in scale): raise ValueError( "`scale` values cannot be zero." ) @classmethod def _validate_direction(cls, direction): if not isinstance(direction, (tuple, list)): raise TypeError( "`direction` must be a tuple or list of 3 direction values." ) if len(direction) != 3: raise ValueError( "`direction` must contain exactly 3 values (x_dir, y_dir, z_dir)." ) if not all(isinstance(v, (int, float)) for v in direction): raise TypeError( "All `direction` values must be numeric." ) if any(v != 1 and v != -1 for v in direction): raise ValueError( "`direction` values must be either 1 or -1." )
from typing import Tuple import math Vec3 = Tuple[float, float, float] @dataclass(frozen=True) class Camera3D: """Immutable camera configuration for a PyVista Plotter. Parameters ---------- position : (x, y, z) Where the camera sits in world space. focal_point : (x, y, z) The point the camera looks at. Defaults to the origin. view_up : (x, y, z) Which direction is "up" for the camera. Defaults to +Z. zoom : float Zoom factor applied after positioning (1.0 = no zoom). Usage ----- cam = Camera3D.isometric() cam.apply(plotter) # or inline: Camera3D(position=(5, 5, 5), focal_point=(0, 0, 0)).apply(plotter) """ position: Vec3 = (5.0, 5.0, 5.0) focal_point: Vec3 = (0.0, 0.0, 0.0) view_up: Vec3 = (0.0, 0.0, 1.0) zoom: float = 1.0 # ------------------------------------------------------------------ # # Core method # # ------------------------------------------------------------------ # def apply(self, plotter) -> None: """Push this camera configuration to a pyvista.Plotter instance.""" plotter.camera_position = [ self.position, self.focal_point, self.view_up, ] plotter.camera.zoom(self.zoom) # ------------------------------------------------------------------ # # Derived helpers # # ------------------------------------------------------------------ # def with_zoom(self, zoom: float) -> "Camera3D": """Return a copy with a different zoom factor.""" return Camera3D(self.position, self.focal_point, self.view_up, zoom) def looking_at(self, focal_point: Vec3) -> "Camera3D": """Return a copy aimed at a different focal point.""" return Camera3D(self.position, focal_point, self.view_up, self.zoom) def translated(self, dx: float = 0, dy: float = 0, dz: float = 0) -> "Camera3D": """Shift camera position (and focal point) by a delta.""" p = (self.position[0] + dx, self.position[1] + dy, self.position[2] + dz) f = (self.focal_point[0] + dx, self.focal_point[1] + dy, self.focal_point[2] + dz) return Camera3D(p, f, self.view_up, self.zoom) # ------------------------------------------------------------------ # # Presets — call as Camera3D.front(), Camera3D.isometric(), … # # ------------------------------------------------------------------ # @classmethod def front(cls, distance: float = 6.0) -> "Camera3D": """Straight-on front view (+Y axis).""" return cls(position=(0.0, -distance, 0.0), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def back(cls, distance: float = 6.0) -> "Camera3D": """Rear view (-Y axis).""" return cls(position=(0.0, distance, 0.0), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def left(cls, distance: float = 6.0) -> "Camera3D": """Left-side view (-X axis).""" return cls(position=(-distance, 0.0, 0.0), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def right(cls, distance: float = 6.0) -> "Camera3D": """Right-side view (+X axis).""" return cls(position=(distance, 0.0, 0.0), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def top(cls, distance: float = 6.0) -> "Camera3D": """Top-down plan view (+Z axis).""" return cls(position=(0.0, 0.0, distance), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 1.0, 0.0)) @classmethod def bottom(cls, distance: float = 6.0) -> "Camera3D": """Bottom-up view (-Z axis).""" return cls(position=(0.0, 0.0, -distance), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 1.0, 0.0)) @classmethod def isometric(cls, distance: float = 6.0) -> "Camera3D": """Classic +X +Y +Z isometric corner.""" d = distance / math.sqrt(3) return cls(position=(d, d, d), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def isometric_sw(cls, distance: float = 6.0) -> "Camera3D": """-X -Y +Z corner (south-west).""" d = distance / math.sqrt(3) return cls(position=(-d, -d, d), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def isometric_se(cls, distance: float = 6.0) -> "Camera3D": """+X -Y +Z corner (south-east).""" d = distance / math.sqrt(3) return cls(position=(d, -d, d), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def isometric_nw(cls, distance: float = 6.0) -> "Camera3D": """-X +Y +Z corner (north-west).""" d = distance / math.sqrt(3) return cls(position=(-d, d, d), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0)) @classmethod def oblique(cls, azimuth: float = 45.0, elevation: float = 30.0, distance: float = 6.0) -> "Camera3D": """Arbitrary spherical position. Parameters ---------- azimuth : degrees, 0 = +Y axis, CCW when viewed from above elevation : degrees above the XY plane distance : radius from the focal origin """ az = math.radians(azimuth) el = math.radians(elevation) x = distance * math.cos(el) * math.sin(az) y = distance * math.cos(el) * math.cos(az) z = distance * math.sin(el) return cls(position=(x, y, z), focal_point=(0.0, 0.0, 0.0), view_up=(0.0, 0.0, 1.0))