diff --git a/pyproject.toml b/pyproject.toml index b00aa268b6b..422a9658a57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,7 +161,7 @@ recursive = true exclude = ["venv/*", "tests/*"] [tool.mypy] -python_version = 3.10 +python_version = "3.10" strict = false namespace_packages = true explicit_package_bases = true diff --git a/src/ansys/pyensight/core/utils/views.py b/src/ansys/pyensight/core/utils/views.py index e8dcd639a0a..f7fe6e1e879 100644 --- a/src/ansys/pyensight/core/utils/views.py +++ b/src/ansys/pyensight/core/utils/views.py @@ -18,9 +18,13 @@ """ + import math +from types import ModuleType from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +import numpy as np + if TYPE_CHECKING: try: import ensight @@ -28,14 +32,239 @@ from ansys.api.pyensight import ensight_api +VIEW_DICT = { + "x+": (1, 0, 0), + "x-": (-1, 0, 0), + "y+": (0, 1, 0), + "y-": (0, -1, 0), + "z+": (0, 0, 1), + "z-": (0, 0, -1), + "isometric": (1, 1, 1), +} + + +class _Simba: + """Hidden class to manage the interactor layer in simba""" + + def __init__(self, ensight: Union["ensight_api.ensight", "ensight"], views: "Views"): + self.ensight = ensight + self.views = views + self._original_look_at = None + self._original_look_from = None + self._original_parallel_scale = None + self._original_view_angle = None + self._original_view_up = None + + def _initialize_simba_view(self): + """Initialize the data for resetting the camera.""" + vport = self.ensight.objs.core.VPORTS[0] + near_clip = vport.ZCLIPLIMITS[0] + view_angle = 2 * vport.PERSPECTIVEANGLE + self._original_parallel_scale = near_clip * math.tan(math.radians(view_angle) / 2) + self._original_view_angle = view_angle + ( + self._original_look_from, + self._original_look_at, + self._original_view_up, + self._original_parallel_scale, + ) = self.compute_camera_from_ensight_opengl() + self.ensight.annotation.axis_global("off") + self.ensight.annotation.axis_local("off") + self.ensight.annotation.axis_model("off") + + def get_center_of_rotation(self): + """Get EnSight center of rotation.""" + return self.ensight.objs.core.VPORTS[0].TRANSFORMCENTER + + def auto_scale(self): + """Auto scale view.""" + self.ensight.view_transf.fit() + self._initialize_simba_view() + self.render() + return self.get_camera() + + def set_view(self, value: str): + """Set the view.""" + if value != "isometric": + new_value = value[1].upper() + value[0] + self.ensight.view_transf.view_recall(new_value) + else: + self.views.set_view_direction( + 1, 1, 1, perspective=self.ensight.objs.core.vports[0].PERSPECTIVE + ) + self.auto_scale() + return self.get_camera() + + def get_camera(self): + """Get EnSight camera settings in VTK format.""" + vport = self.ensight.objs.core.VPORTS[0] + position, focal_point, view_up, parallel_scale = self.compute_camera_from_ensight_opengl() + vport = self.ensight.objs.core.VPORTS[0] + view_angle = 2 * vport.PERSPECTIVEANGLE + # The parameter parallel scale is the actual parallel scale only + # if the vport is in orthographic mode. If not, it is defined as the + # inverge of the tangent of half of the field of view + parallel_scale = parallel_scale + return { + "orthographic": not vport.PERSPECTIVE, + "view_up": view_up, + "position": position, + "focal_point": focal_point, + "view_angle": view_angle, + "parallel_scale": parallel_scale, + "reset_focal_point": self._original_look_at, + "reset_position": self._original_look_from, + "reset_parallel_scale": self._original_parallel_scale, + "reset_view_up": self._original_view_up, + "reset_view_angle": self._original_view_angle, + } + + @staticmethod + def normalize(v): + """Normalize a numpy vector.""" + norm = np.linalg.norm(v) + return v / norm if norm > 0 else v + + @staticmethod + def rotation_matrix_to_quaternion(m): + """Convert a numpy rotation matrix to a quaternion.""" + trace = np.trace(m) + if trace > 0: + s = 0.5 / np.sqrt(trace + 1.0) + w = 0.25 / s + x = (m[2, 1] - m[1, 2]) * s + y = (m[0, 2] - m[2, 0]) * s + z = (m[1, 0] - m[0, 1]) * s + else: + if m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]: + s = 2.0 * np.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) + w = (m[2, 1] - m[1, 2]) / s + x = 0.25 * s + y = (m[0, 1] + m[1, 0]) / s + z = (m[0, 2] + m[2, 0]) / s + elif m[1, 1] > m[2, 2]: + s = 2.0 * np.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) + w = (m[0, 2] - m[2, 0]) / s + x = (m[0, 1] + m[1, 0]) / s + y = 0.25 * s + z = (m[1, 2] + m[2, 1]) / s + else: + s = 2.0 * np.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) + w = (m[1, 0] - m[0, 1]) / s + x = (m[0, 2] + m[2, 0]) / s + y = (m[1, 2] + m[2, 1]) / s + z = 0.25 * s + return np.array([x, y, z, w]) + + def compute_model_rotation_quaternion(self, camera_position, focal_point, view_up): + """Compute the quaternion from the input camera.""" + forward = self.normalize(np.array(focal_point) - np.array(camera_position)) + right = self.normalize(np.cross(forward, view_up)) + true_up = np.cross(right, forward) + camera_rotation = np.vstack([right, true_up, -forward]).T + model_rotation = camera_rotation.T + quat = self.rotation_matrix_to_quaternion(model_rotation) + return quat + + @staticmethod + def quaternion_multiply(q1, q2): + x1, y1, z1, w1 = q1 + x2, y2, z2, w2 = q2 + w = w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2 + x = w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2 + y = w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2 + z = w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2 + return np.array([x, y, z, w]) + + def quaternion_to_euler(self, q): + q = self.normalize(q) + x, y, z, w = q + sinr_cosp = 2 * (w * x + y * z) + cosr_cosp = 1 - 2 * (x * x + y * y) + roll = np.arctan2(sinr_cosp, cosr_cosp) + + sinp = 2 * (w * y - z * x) + if abs(sinp) >= 1: + pitch = np.pi / 2 * np.sign(sinp) + else: + pitch = np.arcsin(sinp) + siny_cosp = 2 * (w * z + x * y) + cosy_cosp = 1 - 2 * (y * y + z * z) + yaw = np.arctan2(siny_cosp, cosy_cosp) + + return np.degrees([roll, pitch, yaw]) + + def compute_camera_from_ensight_opengl(self): + """Simulate a rotating camera using the current quaternion.""" + if isinstance(self.ensight, ModuleType): + data = self.ensight.objs.core.VPORTS[0].simba_camera() + else: + data = self.ensight._session.cmd("ensight.objs.core.VPORTS[0].simba_camera())") + camera_position = [data[0], data[1], data[2]] + focal_point = [data[3], data[4], data[5]] + view_up = [data[6], data[7], data[8]] + parallel_scale = 1 / data[9] + return camera_position, focal_point, self.views._normalize_vector(view_up), parallel_scale + + def set_camera( + self, orthographic, view_up=None, position=None, focal_point=None, view_angle=None + ): + """Set the EnSight camera settings from the VTK input.""" + perspective = "OFF" if orthographic else "ON" + if orthographic: + self.ensight.view.perspective(perspective) + vport = self.ensight.objs.core.VPORTS[0] + if view_angle: + vport.PERSPECTIVEANGLE = view_angle / 2 + + if view_up and position and focal_point: + q_current = self.normalize(np.array(vport.ROTATION.copy())) + q_target = self.normalize( + self.compute_model_rotation_quaternion(position, focal_point, view_up) + ) + q_relative = self.quaternion_multiply( + q_target, np.array([-q_current[0], -q_current[1], -q_current[2], q_current[3]]) + ) + angles = self.quaternion_to_euler(q_relative) + self.ensight.view_transf.rotate(*angles) + self.render() + + def set_perspective(self, value): + vport = self.ensight.objs.core.VPORTS[0] + self.ensight.view.perspective(value) + vport.PERSPECTIVE = value == "ON" + self.ensight.view_transf.zoom(1) + self.ensight.view_transf.rotate(0, 0, 0) + self.render() + return self.get_camera() + + def screen_to_world(self, mousex, mousey, invert_y=False, set_center=False): + mousex = int(mousex) + mousey = int(mousey) + if isinstance(self.ensight, ModuleType): + model_point = self.ensight.objs.core.VPORTS[0].screen_to_coords( + mousex, mousey, invert_y, set_center + ) + else: + model_point = self.ensight._session.cmd( + f"ensight.objs.core.VPORTS[0].screen_to_coords({mousex}, {mousey}, {invert_y}, {set_center})" + ) + self.render() + return {"model_point": model_point, "camera": self.get_camera()} + + def render(self): + self.ensight.render() + self.ensight.refresh(1) + + class Views: """Controls the view in the current EnSight ``Session`` instance.""" def __init__(self, ensight: Union["ensight_api.ensight", "ensight"]): self.ensight = ensight self._views_dict: Dict[str, Tuple[int, List[float]]] = {} + self._simba = _Simba(ensight, self) - # Utilities @staticmethod def _normalize_vector(direction: List[float]) -> List[float]: """Return the normalized input (3D) vector. @@ -295,14 +524,17 @@ def set_view_direction( vportindex : int, optional Viewport to set the view direction for. The default is ``0``. """ - self.ensight.view.perspective("OFF") - direction = [xdir, ydir, zdir] vport = self.ensight.objs.core.VPORTS[vportindex] + if not perspective: + self.ensight.view.perspective("OFF") + vport.PERSPECTIVE = False + direction = [xdir, ydir, zdir] rots = vport.ROTATION.copy() rots[0:4] = self._convert_view_direction_to_quaternion(direction, up_axis=up_axis) vport.ROTATION = rots if perspective: self.ensight.view.perspective("ON") + vport.PERSPECTIVE = True self.save_current_view(name=name, vportindex=vportindex) def save_current_view(