import paraview.simple as pv
import numpy as np
from ..other import Vector3, is_valid_extension
from ..logging import logger
from . import Layout, Config
[docs]
class Exporter:
def __init__(self, layout: Layout, config: Config):
"""
Initialize an exporter responsible for creating images and videos from a layout.
The exporter takes a layout and saves images and animations to the set plots path inside a config instance.
:param Layout layout: The layout object to export.
:param Config config: The configuration object containing the output directories
for saving exported images and videos.
:return: None
:raises TypeError: If the provided layout is not a ``Layout`` instance or the
config is not a ``Config`` instance.
"""
self.layout = layout
self.config = config
if not isinstance(layout, Layout):
raise TypeError(f"Provided a {type(layout).__name__} instead of a layout to an Exporter. Please provide a Layout object.")
if not isinstance(config, Config):
raise TypeError(f"Provided a {type(config).__name__} instead of a config to an Exporter. Please provide a Config object.")
[docs]
def save_snapshot(self, filename: str):
"""
Save a single screenshot of the layout at the first timestep.
:param str filename: The base filename (without extension) for the exported image.
:return: None
"""
logger.info(f"Exporting snapshot of layout to {self.config.get_plot_path()}{filename}.png.")
self.layout.prepare_export()
pv.SaveScreenshot(self.config.get_plot_path() + filename + ".png", self.layout.get_layout())
[docs]
def save_at_timesteps(self, filename: str, timesteps: list[float]):
"""
Save the layout as PNG images at the specified timesteps.
This behaves like ``save_animation`` in image mode (e.g., PNG export),
but uses explicit timestep values rather than frame indices.
:param str filename: The base filename (without extension) for exported images.
:param list[float] timesteps: The list of timesteps at which images should be saved.
:return: None
"""
logger.info(f"Exporting snapshot at {len(timesteps)} timesteps to {self.config.get_plot_path()}{filename}.")
self.layout.prepare_export()
animation_scene = pv.GetAnimationScene()
prev_timestep: float = animation_scene.AnimationTime
for i in range(0, len(timesteps)):
animation_scene.AnimationTime = timesteps[i]
self.save_snapshot(filename=filename + "_" + str(timesteps[i]))
animation_scene.AnimationTime = prev_timestep
[docs]
def save_at_all_timesteps(self, filename: str):
"""
Save the layout as PNG images at all available timesteps.
This behaves like ``save_animation`` in image mode (PNG export),
but saves frames at actual timestep values instead of frame indices.
:param str filename: The base filename (without extension) for exported images.
:return: None
"""
animation_scene = pv.GetAnimationScene()
timesteps = animation_scene.TimeKeeper.TimestepValues
self.save_at_timesteps(filename, timesteps)
[docs]
def save_animation(self, filename: str, start_frame: int = 0, end_frame: int = -1,
framerate: int = 30, format: str = ".ogv"):
"""
Save an animation of the layout.
This method exports either a video (e.g., ``.ogv``) or a sequence of image frames
(e.g., ``.png`` or ``.jpg``), depending on the file extension provided.
:param str filename: The base filename (without extension) for the animation export.
:param int start_frame: The index of the first frame to render (must be ≥ 0).
:param int end_frame: The index of the last frame to render. If ``-1`` or greater than
the number of frames, it is automatically clamped to the maximum available frame.
:param int framerate: The framerate of the exported video.
:param str format: The output format (e.g., ``".png"``, ``".jpg"``, ``".jpeg"``, ``".ogv"``).
:return: None
"""
logger.info(f"Exporting animation to {self.config.get_plot_path()}{filename}{format}.")
if not is_valid_extension(format, [".png", ".jpg", ".jpeg", ".ogv"]):
return
if start_frame < 0:
logger.error(f"Cannot export animation where start frame ({start_frame}) is negative.")
return
animation_scene = pv.GetAnimationScene()
animation_scene.UpdateAnimationUsingDataTimeSteps()
max_frames: int = len(animation_scene.TimeKeeper.TimestepValues)
if end_frame == -1 or end_frame > max_frames:
end_frame = max_frames
if end_frame <= start_frame:
logger.error(f"Cannot export animation where end frame ({end_frame}) is before start frame ({start_frame}).")
return
logger.info(f"Exporting frames {start_frame} to {end_frame}.")
self.layout.prepare_export()
pv.SaveAnimation(
self.config.get_plot_path() + filename + format,
self.layout.get_layout(),
FrameWindow=[start_frame, end_frame + 1],
FrameRate=framerate
)
[docs]
def save_camera_orbit_animation(self, orbiting_visualization: "Visualization3D", filename: str,
start_time: int, end_time: int, nr_frames: int, framerate: int,
format: str = ".ogv"):
"""
Save an animation of a camera orbiting around a focal point in a 3D visualization.
This creates a camera animation path (orbit) and renders frames along the path.
The output can be an image sequence or a video depending on the file extension.
:param Visualization3D orbiting_visualization: The 3D visualization whose camera should orbit.
:param str filename: The base filename (without extension) for the exported animation.
:param int start_time: The starting timestep of the animation.
:param int end_time: The ending timestep of the animation.
:param int nr_frames: The number of frames to generate between the start and end time.
:param int framerate: The framerate for exported video formats.
:param str format: The output format (e.g. ``".png"``, ``".jpg"``, ``".ogv"``).
:return: None
"""
logger.info(f"Exporting camera orbit animation to {self.config.get_plot_path()}{filename}{format}.")
if not is_valid_extension(format, [".png", ".jpg", ".jpeg", ".ogv"]):
return
self.layout.prepare_export()
# configure animation scene
animation_scene = self._configure_animation_scene(start_time=start_time, end_time=end_time,
nr_frames=nr_frames, framerate=framerate)
animation_scene.UpdateAnimationUsingDataTimeSteps()
render_view = orbiting_visualization.get_render_view()
# compute camera orbit points
points = self._compute_points_on_circle(
Vector3.from_list(render_view.CameraFocalPoint),
orbiting_visualization.get_cam_up()
)
camera_animation_cue = pv.GetCameraTrack(view=render_view)
# create start key frame
key_frame_start = pv.CameraKeyFrame()
key_frame_start.Position = [points[0], points[1], points[2]]
key_frame_start.FocalPoint = render_view.CameraFocalPoint
key_frame_start.ViewUp = render_view.CameraViewUp
key_frame_start.ParallelScale = render_view.CameraParallelScale
key_frame_start.PositionPathPoints = points
key_frame_start.FocalPathPoints = render_view.CameraFocalPoint
key_frame_start.ClosedPositionPath = 1
# create end key frame
key_frame_end = pv.CameraKeyFrame()
key_frame_end.KeyTime = 1.0
key_frame_end.Position = key_frame_start.Position
key_frame_end.FocalPoint = render_view.CameraFocalPoint
key_frame_end.ViewUp = render_view.CameraViewUp
key_frame_end.ParallelScale = render_view.CameraParallelScale
# configure animation scene
camera_animation_cue.Mode = 'Path-based'
camera_animation_cue.KeyFrames = [key_frame_start, key_frame_end]
# play animation
animation_scene.Play()
pv.SaveAnimation(
self.config.get_plot_path() + filename + format,
self.layout.get_layout(),
FrameWindow=[0, nr_frames - 1],
FrameRate=framerate
)
# private function to configure the animation scene
def _configure_animation_scene(self, start_time, end_time, nr_frames, framerate):
animation_scene = pv.GetAnimationScene()
animation_scene.StartTime = start_time
animation_scene.EndTime = end_time
animation_scene.NumberOfFrames = nr_frames
animation_scene.FramesPerTimestep = framerate
return animation_scene
# private function to compute the camera positions of an orbiting camera around an origin
# returns the computed camera positions
def _compute_points_on_circle(self, origin: Vector3, cam_up: Vector3):
points = np.empty(10 * 3)
# transform cam up vector
cam_up = cam_up - 1
cam_up = cam_up * -1
# calculate points of circle in plane
for i in range(0, 10 * 3, 3):
points[i] = origin.x + cam_up.x * np.cos(2 * np.pi * i / 10)
points[i + 1] = origin.y + cam_up.y * np.sin(2 * np.pi * i / 10)
if points[i] == origin.x:
points[i + 2] = origin.z + cam_up.z * np.cos(2 * np.pi * i / 10)
else:
points[i + 2] = origin.z + cam_up.z * np.sin(2 * np.pi * i / 10)
return points