Source code for vifpara.views.visualization_3D

import paraview.simple as pv

import numpy as np
from scipy.spatial.transform import Rotation as R
import scipy as sp
import warnings

from ..logging import logger
from ..other import Vector3
from ..base import Layout, Clipbox, Case
from . import IViewObject, Slice

import random


[docs] class Visualization3D(IViewObject): def __init__(self, case: Case, cam_position: Vector3, cam_up: Vector3 = Vector3(0, 0, 1), focal_point: Vector3 = Vector3(0, 0, 0), width: int = 900, height: int = 720, zoom: float = 1.0, show_orientation_axis: bool = False, show_color_bar: bool = True): """ Initialize a 3D visualization view for displaying slice planes and geometry. The 3D visualization renders the case or clipbox as is into a layout cell. Slices can additionally be added to render their position on the case. This allows for an "overview" of your the slices you defined. :param Case case: The loaded visualization case. :param Vector3 cam_position: The world‑space position of the camera. :param Vector3 cam_up: The camera's up vector. Defaults to ``(0, 0, 1)``. :param Vector3 focal_point: The point the camera looks at. Defaults to ``(0, 0, 0)``. :param int width: The width of the 3D render view in pixels. :param int height: The height of the 3D render view in pixels. :param float zoom: Zoom factor for the camera. ``2.0`` → twice the size, ``0.5`` → half the size. :param bool show_orientation_axis: Whether to show the ParaView orientation axis. :param bool show_color_bar: Whether to show a color bar in the 3D view. :return: None """ super().__init__(case, width, height, show_orientation_axis) self._cam_position: Vector3 = cam_position self._cam_up: Vector3 = cam_up self._camera_scale: float = 1.0 / zoom self._show_color_bar: bool = show_color_bar self._render_view.CameraFocalPoint = focal_point.to_list() self._render_view.CameraPosition = self._cam_position.to_list() self._render_view.CameraViewUp = self._cam_up.to_list() self._render_view.CameraParallelProjection = 1 self._render_view.CameraParallelScale = self._camera_scale pv.SetActiveView(None) pv.SetActiveView(self._render_view)
[docs] def add_case_or_clip_to_view(self, case: Case, color_map=None, opacity=1.0, representation_type='Surface', gaussian_radius=0.0012, field_type='Points', representation_point_size: float = 1.0): """ Add a Case or Clipbox object to the 3D visualization. This function: - Shows the case or clip object in the 3D render view. - Applies an optional color map. - Sets the representation, opacity, point size, and additional rendering options. - Supports both point-based and cell-based field data. - Can render Gaussian-point representations when using ``'Point Gaussian'``. :param Case case: The Case or Clipbox instance to visualize. :param ColorMap color_map: The color map applied to the object. If ``None``, the object is shown in gray. :param float opacity: The opacity of the displayed object (0.0–1.0). :param str representation_type: The data representation (e.g. ``"Surface"``, ``"Points"``, ``"Point Gaussian"``). :param float gaussian_radius: Radius used for Gaussian point rendering (only relevant for ``"Point Gaussian"`` representation). :param str field_type: Field data type. Set to ``"Cells"`` for cell-only datasets, otherwise ``"Points"``. :param float representation_point_size: Size of points when using point-based representations. :return: None """ logger.info(f"Adding case {case} to 3D Visualization.") if representation_point_size <= 0.0: logger.error(f"Invalid representation point size given ({representation_point_size}). " f"Must be larger than 0.0.") return environment = None if isinstance(case, Clipbox): environment = case.get_clip() elif isinstance(case, Case): environment = case.get_case() else: raise TypeError( f"Invalid object of type {type(case).__name__} provided to add_case_or_clip_to_view(). " f"Must be of type Case or Clipbox." ) # activate render view pv.SetActiveView(None) pv.SetActiveView(self._render_view) # show object in render_view self._display = pv.Show(environment, self._render_view) # add color map to render_view if color_map is not None: color_map.apply_to_render_view(self._display, self._render_view, render=self._show_color_bar) else: pv.ColorBy(self._display, None) # set display opacity and representation self._display.Opacity = opacity self._display.SetRepresentationType(representation_type) self._display.PointSize = representation_point_size # set color array name if no point data is available if field_type == 'Cells' and color_map is not None: self._display.ColorArrayName = ['CELLS', color_map.get_field()] # define gaussian radius for particles if representation_type == 'Point Gaussian': self._display.GaussianRadius = gaussian_radius
[docs] def add_slice_to_view(self, slice: Slice, text='', opacity=0.4, text_position=None, flip_y_label=False, text_scale: float = 0.05, slice_scale: float = 1.0): """ Add a slice plane and optional text annotation to the 3D view. This method: - Positions a slice plane in the 3D view according to its geometric definition. - Applies opacity and scaling. - Optionally adds a 3D text label aligned with slice orientation and camera direction. - Automatically computes transform, rotation, and placement of text. :param Slice slice: The slice whose geometric plane should be added to the 3D view. :param str text: Optional annotation text displayed near the slice. If empty, no text is shown. :param float opacity: Opacity of the slice plane (0.0–1.0). :param tuple|list|None text_position: Optional 3‑component position where the text should appear. :param bool flip_y_label: Whether the text’s y‑orientation should be flipped. Useful when the camera’s up‑vector is aligned along the y‑axis. :param float text_scale: Scale factor for the 3D text annotation. :param float slice_scale: Scaling factor applied to the slice plane geometry. :return: None """ logger.info(f"Adding slice {slice} to 3D Visualization.") pv.SetActiveView(None) pv.SetActiveView(self._render_view) plane: pv.Plane = self._slice_to_plane(slice, slice_scale) display = pv.Show(plane, self._render_view) # set display opacity display.Opacity = opacity if text != '': # create and set text text_3D = pv.a3DText(registrationName='3DText') text_3D.Text = text # create transform transform = pv.Transform(registrationName='Transform', Input=text_3D) transform.Transform = 'Transform' # compute translation and rotation for text alignment transform.Transform.Translate = self._compute_translation(text_position, slice) transform.Transform.Rotate = self._compute_euler_angles_rotation(slice, flip_y_label) transform.Transform.Scale = (text_scale, text_scale, text_scale) # display transformed text in 3D view text_display = pv.Show(transform, self._render_view, 'GeometryRepresentation') text_display.AmbientColor = [0.0, 0.0, 0.0] text_display.DiffuseColor = [0.0, 0.0, 0.0] text_display.Opacity = 1.0
def _render_inside(self, layout: Layout, row: int=0, col: int=0): """ Renders the visualization 3D into a cell in the layout. Parameters: ---------- layout: Layout The layout to render the visualization in. row: int The row to render the visualization in. col: int The col in the given row to render the visualization in. """ logger.info(f"Rendering Visualization 3D to <{row}|{col}>.") layout.add_render_view(row, col, self) # create slice based on origin, normal and type pv.SetActiveView(None) pv.SetActiveView(self._render_view) def get_render_object(self): return None def get_cam_up(self): return self._cam_up def _slice_to_plane(self, slice: Slice, plane_scale: float = 1.0) -> pv.Plane: """Create a ParaView plane that spans the bounding box of the slice object.""" # Get slice properties plane_normal = np.array(slice.get_normal().to_list()) # slice_origin = np.array(slice.get_origin().to_list()) # Get bounding box of the object (x_min, x_max, y_min, y_max, z_min, z_max) = slice.get_render_object().GetDataInformation().GetBounds() # Compute bounding box center and extents center = np.array([ 0.5 * (x_min + x_max), 0.5 * (y_min + y_max), 0.5 * (z_min + z_max), ]) extent = np.array([ (x_max - x_min), (y_max - y_min), (z_max - z_min), ]) # Find a vector that's not parallel to the normal if abs(plane_normal[0]) < 0.9: temp_vector = np.array([1, 0, 0]) else: temp_vector = np.array([0, 1, 0]) # Create perpendicular basis perp_vector1 = np.cross(plane_normal, temp_vector) perp_vector1 /= np.linalg.norm(perp_vector1) perp_vector2 = np.cross(plane_normal, perp_vector1) perp_vector2 /= np.linalg.norm(perp_vector2) # Project the bounding box extents onto perp vectors size1 = np.dot(extent, np.abs(perp_vector1)) * plane_scale size2 = np.dot(extent, np.abs(perp_vector2)) * plane_scale # Define the plane spanning the bounding box in slice coordinates plane = pv.Plane(registrationName=f"Plane_{random.randint(0, 1000)}") plane_origin = (center - 0.5 * size1 * perp_vector1 - 0.5 * size2 * perp_vector2).tolist() plane.Origin = plane_origin plane.Point1 = (np.array(plane_origin) + size1 * perp_vector1).tolist() plane.Point2 = (np.array(plane_origin) + size2 * perp_vector2).tolist() return plane # private function _compute_euler_angles_rotation # computes text rotation in euler angles # returns euler angles in x,y,z format def _compute_euler_angles_rotation(self, obj, flip_y_label): text_normal = np.array([0, 0, 1]) # default creation value text_up = np.array([0, 1, 0]) # default creation value slice_normal = np.array(obj.get_normal().to_list()) up_vector1 = self._cam_up.to_list() up_vector2 = text_up norm_vector1 = [0, 0, 0] norm_vector2 = text_normal # check if camera up and slice normal are equal if np.array_equal(self._cam_up.to_list(), slice_normal): # set text normal facing to camera norm_vector1 = np.array(self._cam_position.to_list()) - np.array(self._render_view.CameraFocalPoint) norm_vector1 = np.rint(norm_vector1 / np.linalg.norm(norm_vector1)).astype(int) else: # check if slice normal is facing the camera, otherwise transform if np.dot(self._render_view.CameraFocalPoint - np.array(self._cam_position.to_list()), slice_normal) > 0: slice_normal = slice_normal * -1 # set text normal to slice normal norm_vector1 = slice_normal # define transformation pairs, vec_pair1 is camera up vector and final text normal, # vec_pair2 is text up and text normal vec_pair1 = [up_vector1, norm_vector1] vec_pair2 = [up_vector2, norm_vector2] # compute rotation to align transformation pairs rot, rmsd = R.align_vectors(a=vec_pair1, b=vec_pair2) rot_final = np.array([0, 0, 0]) sp.special.seterr(all='raise') with warnings.catch_warnings(): warnings.simplefilter("error") try: rot_final = rot.as_euler('xyz', degrees=True) except UserWarning: if np.array_equal(self._cam_up, [1, 0, 0]): logger.warning("Catched gimbal lock") rot_final = rot.as_euler('zyz', degrees=True) if flip_y_label: rot_final = np.array([0, -rot_final[1], rot_final[0] + rot_final[2]]) else: rot_final = np.array([0, rot_final[1], rot_final[0] + rot_final[2]]) else: warnings.simplefilter("ignore") rot_final = rot.as_euler('xyz', degrees=True) warnings.simplefilter("ignore") rot_final = np.rint(rot_final / 90.0) * 90.0 return rot_final # private function _compute_translation # computes translation for text alignment in 3D view # returns translation def _compute_translation(self, translation, obj): # check if translation was set by user if translation is None: (x_min, x_max, y_min, y_max, z_min, z_max) = obj.get_render_object().GetDataInformation().GetBounds() translation = np.array([0.0, 0.0, 0.0]) # compute nearest corner of slice bounds to camera if np.abs(self._cam_position[0] - x_min) > np.abs(self._cam_position[0] - x_max): translation += np.array([x_max, 0.0, 0.0]) else: translation += np.array([x_min, 0.0, 0.0]) if np.abs(self._cam_position[1] - y_min) > np.abs(self._cam_position[1] - y_max): translation += np.array([0.0, y_max, 0.0]) else: translation += np.array([0.0, y_min, 0.0]) if np.abs(self._cam_position[2] - z_min) > np.abs(self._cam_position[2] - z_max): translation += np.array([0.0, 0.0, z_max]) else: translation += np.array([0.0, 0.0, z_min]) return translation