diff --git a/src/compas_viewer/components/renderer/renderer.py b/src/compas_viewer/components/renderer/renderer.py index 1481514118..c9ce8c6c0a 100644 --- a/src/compas_viewer/components/renderer/renderer.py +++ b/src/compas_viewer/components/renderer/renderer.py @@ -241,7 +241,7 @@ def event(self, event): The Qt event. """ - if event.type() == QtCore.QEvent.Gesture: + if event.type() == QtCore.QEvent: return self.gestureEvent(event) return super().event(event) diff --git a/src/compas_viewer/scene/scene.py b/src/compas_viewer/scene/scene.py index d7f24c6f63..c1e1d9cd5f 100644 --- a/src/compas_viewer/scene/scene.py +++ b/src/compas_viewer/scene/scene.py @@ -4,13 +4,15 @@ from typing import Generator from typing import Optional from typing import Union - +from collections import defaultdict from compas.colors import Color from compas.datastructures import Datastructure from compas.geometry import Geometry from compas.scene import Scene from .sceneobject import ViewerSceneObject +from .collectionobject import CollectionObject +from .meshobject import MeshObject def instance_colors_generator(i: int = 0) -> Generator: @@ -183,3 +185,20 @@ def add( ) return sceneobject + + def sort_objects_from_category(self, output_type: str): + sorted_objs = defaultdict(list) + + def sort(obj): + if isinstance(obj, CollectionObject): + [sort(item) for item in obj.objects] + else: + sorted_objs[type(obj)].append(obj) + + for obj in self.objects: + sort(obj) + + if output_type == "MeshObject": + output_type = MeshObject + + return sorted_objs[output_type] diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index efad597126..99c249364b 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -3,6 +3,7 @@ from compas_viewer.base import Base from compas_viewer.components.treeform import Treeform +from compas_viewer.view3d.view3d import View3D class SideBarRight(Base): @@ -10,7 +11,7 @@ def __init__(self) -> None: super().__init__() self.side_right_widget = None - def setup_sidebar_right(self) -> None: + def lazy_init(self) -> None: self.side_right_widget = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) self.side_right_widget.setChildrenCollapsible(True) self.side_right_widget.addWidget(Treeform(self.viewer.scene, {"Name": (lambda o: o.name), "Object": (lambda o: o)})) @@ -23,10 +24,10 @@ def __init__(self): self.sidebar_right = SideBarRight() def lazy_init(self) -> None: - self.sidebar_right.setup_sidebar_right() + self.sidebar_right.lazy_init() self.viewport_widget = QtWidgets.QSplitter() - self.viewport_widget.addWidget(self.viewer.renderer) + self.viewport_widget.addWidget(self.viewer.view3d) self.viewport_widget.addWidget(self.sidebar_right.side_right_widget) self.viewport_widget.setSizes([800, 200]) self.viewer.ui.window.centralWidget().layout().addWidget(self.viewport_widget) diff --git a/src/compas_viewer/view3d/camera.py b/src/compas_viewer/view3d/camera.py new file mode 100644 index 0000000000..aa1d741f22 --- /dev/null +++ b/src/compas_viewer/view3d/camera.py @@ -0,0 +1,432 @@ +from math import atan2 +from math import radians +from math import tan +from typing import Callable +from typing import Optional + +from numpy import array +from numpy import asfortranarray +from numpy import dot +from numpy import float32 +from numpy import pi +from numpy.linalg import det +from numpy.linalg import norm + +from compas.geometry import Rotation +from compas.geometry import Transformation +from compas.geometry import Translation +from compas.geometry import Vector + + +class Position(Vector): + """ + The position of the camera. + + Parameters + ---------- + vector : tuple[float, float, float] + The position of the camera. + on_update : Callable + A callback function that is called when the position changes. + + """ + + def __init__(self, vector: tuple[float, float, float], on_update: Optional[Callable] = None): + self.on_update = on_update + self.pause_update = True + super().__init__(*vector) + + @property + def x(self): + return self._x + + @x.setter + def x(self, x): + if self.on_update is not None and not self.pause_update: + self.on_update([x, self.y, self.z]) + self._x = float(x) + + @property + def y(self): + return self._y + + @y.setter + def y(self, y): + if self.on_update is not None and not self.pause_update: + self.on_update([self.x, y, self.z]) + self._y = float(y) + + @property + def z(self): + return self._z + + @z.setter + def z(self, z): + if self.on_update is not None and not self.pause_update: + self.on_update([self.x, self.y, z]) + self._z = float(z) + + def set(self, x: float, y: float, z: float, pause_update: bool = False): + """Set the position of the camera.""" + pause_update = pause_update or self.pause_update + if self.on_update is not None and not pause_update: + self.on_update([x, y, z]) + self._x = x + self._y = y + self._z = z + + +class RotationEuler(Position): + pass + + +class Camera: + """Camera object for the default view. + + Parameters + ---------- + renderer : :class:`compas_viewer.components.renderer.Renderer`, + The parent renderer of the camera. + + Attributes + ---------- + config : :class:`compas_viewer.configurations.render_config.CameraConfig` + + Notes + ----- + The camera is defined by the following parameters which can be found in: + :class:`compas_viewer.configurations.render_config.CameraConfig`: + + fov : float + The field of view as an angler in degrees. + near : float + The location of the "near" clipping plane. + far : float + The location of the "far" clipping plane. + position : :class:`compas_viewer.components.renderer.camera.Position` + The location the camera. + rotation : :class:`compas_viewer.components.renderer.camera.RotationEuler` + The euler rotation of camera. + target : :class:`compas_viewer.components.renderer.camera.Position` + The viewing target. + Default is the origin of the world coordinate system. + distance : float + The distance from the camera standpoint to the target. + zoomdelta : float + Size of one zoom increment. + rotationdelta : float + Size of one rotation increment. + pan_delta : float + Size of one pan increment. + scale : float + The scale factor for camera's near, far and pan_delta. + """ + + def __init__( + self, + fov: Optional[float] = 45.0, + near: Optional[float] = 0.1, + far: Optional[float] = 1000.0, + init_position: Optional[tuple] = [10.0, 10.0, 10.0], + init_target: Optional[tuple] = [0.0, 0.0, 0.0], + scale: Optional[float] = 1.0, + zoomdelta: Optional[float] = 0.05, + rotationdelta: Optional[float] = 0.01, + pan_delta: Optional[float] = 0.05, + ) -> None: + self.fov = fov + self.near = near + self.far = far + self.scale = scale + self.zoomdelta = zoomdelta + self.rotationdelta = rotationdelta + self.pan_delta = pan_delta + # TODO: Add viewmode to config + self.viewmode = "perspective" + + self._position = Position(init_position, on_update=self._on_position_update) + self._rotation = RotationEuler((0, 0, 0), on_update=self._on_rotation_update) + self._target = Position(init_target, on_update=self._on_target_update) + self._position.pause_update = False + self._rotation.pause_update = False + self._target.pause_update = False + self.target = Position(init_target) + + def lazy_init(self) -> None: + # Camera position only modifiable in perspective view mode. + self.reset_position() + # if self.viewmode == "perspective": + # self.position = Position(self.config.position) + + @property + def position(self) -> Position: + """The position of the camera.""" + return self._position + + @position.setter + def position(self, position: Position): + self._position.set(*position, pause_update=False) + + @property + def rotation(self) -> RotationEuler: + """The rotation of the camera.""" + return self._rotation + + @rotation.setter + def rotation(self, rotation: RotationEuler): + self._rotation.set(rotation.x, rotation.y, rotation.z) + + @property + def target(self) -> Position: + """The target of the camera.""" + return self._target + + @target.setter + def target(self, target: Position): + self._target.set(*target, pause_update=False) + + @property + def distance(self) -> float: + """The distance from the camera to the target.""" + return (self.position - self.target).length + + @distance.setter + def distance(self, distance: float): + """Update the position based on the distance.""" + direction = self.position - self.target + direction.unitize() + new_position = self.target + direction * distance + self.position.set(*new_position, pause_update=True) + + def ortho(self, left: float, right: float, bottom: float, top: float, near: float, far: float) -> Transformation: + """Construct an orthogonal projection matrix. + + Parameters + ---------- + left : float + Location of the left clipping plane. + right : float + Location of the right clipping plane. + bottom : float + Location of the bottom clipping plane. + top : float + Location of the top clipping plane. + near : float + Location of the near clipping plane. + far : float + Location of the far clipping plane. + + Returns + ------- + :class:`compas.geometry.Transformation` + + """ + dx = right - left + dy = top - bottom + dz = far - near + rx = -(right + left) / dx + ry = -(top + bottom) / dy + rz = -(far + near) / dz + assert dx != 0 and dy != 0 and dz != 0 + matrix = [ + [2.0 / dx, 0, 0, rx], + [0, 2.0 / dy, 0, ry], + [0, 0, -2.0 / dz, rz], + [0, 0, 0, 1], + ] + return Transformation.from_matrix(matrix) + + def perspective(self, fov: float, aspect: float, near: float, far: float) -> Transformation: + """Construct a perspective projection matrix. + + Parameters + ---------- + fov : float + The field of view in degrees. + aspect : float + The aspect ratio of the view. + near : float + Location of the near clipping plane. + far : float + Location of the far clipping plane. + + Returns + ------- + :class:`compas.geometry.Transformation` + + """ + assert near != far + assert aspect != 0 + assert fov != 0 + + sy = 1.0 / tan(radians(fov) / 2.0) + sx = sy / aspect + zz = (far + near) / (near - far) + zw = 2 * far * near / (near - far) + matrix = [[sx, 0, 0, 0], [0, sy, 0, 0], [0, 0, zz, zw], [0, 0, -1, 0]] + return Transformation.from_matrix(matrix) + + def _on_position_update(self, new_position: Position): + """Update camera rotation to keep pointing the target.""" + old_direction = array(self.position - self.target) + new_direction = array(Vector(*new_position) - self.target) + old_distance = norm(old_direction) + new_distance = norm(new_direction) + self.distance *= float(new_distance) / float(old_distance) + + old_direction_xy = old_direction[:2] + new_direction_xy = new_direction[:2] + old_direction_xy_distance = norm(old_direction_xy) + new_direction_xy_distance = norm(new_direction_xy) + + old_direction_pitch = array([old_direction_xy_distance, old_direction[2]]) + new_direction_pitch = array([new_direction_xy_distance, new_direction[2]]) + old_direction_pitch_distance = norm(old_direction_pitch) + new_direction_pitch_distance = norm(new_direction_pitch) + + if new_direction_xy[0] == 0 and new_direction_xy[1] == 0: + new_direction_xy[0] = 0.0001 + + old_direction_xy /= old_direction_xy_distance or 1 + new_direction_xy /= new_direction_xy_distance or 1 + old_direction_pitch /= old_direction_pitch_distance + new_direction_pitch /= new_direction_pitch_distance + + angle_z = atan2(det([old_direction_xy, new_direction_xy]), dot(old_direction_xy, new_direction_xy)) + angle_x = -atan2(det([old_direction_pitch, new_direction_pitch]), dot(old_direction_pitch, new_direction_pitch)) + + new_rotation = self.rotation + [angle_x or 0, 0, angle_z or 0] + self.rotation.set(*new_rotation, pause_update=True) + + def _on_rotation_update(self, rotation): + """Update camera position when rotation around target.""" + R = Rotation.from_euler_angles(rotation) + T = Translation.from_vector([0, 0, self.distance]) + M = (R * T).matrix + vector = [M[i][3] for i in range(3)] + position = self.target + vector + + self.position.set(*position, pause_update=True) + + def _on_target_update(self, target: Position): + """Update camera position when target changes.""" + R = Rotation.from_euler_angles(self.rotation) + T = Translation.from_vector([0, 0, self.distance]) + M = (R * T).matrix + vector = [M[i][3] for i in range(3)] + position = Vector(*target) + Vector(*vector) + + self.target.set(*target, pause_update=True) + self.position.set(*position, pause_update=True) + + def reset_position(self): + """Reset the position of the camera based current view type.""" + self.target.set(0, 0, 0, False) + if self.viewmode == "perspective": + self.rotation.set(pi / 4, 0, -pi / 4, False) + if self.viewmode == "top": + self.rotation.set(0, 0, 0, False) + if self.viewmode == "front": + self.rotation.set(pi / 2, 0, 0, False) + if self.viewmode == "right": + self.rotation.set(pi / 2, 0, pi / 2, False) + + def rotate(self, dx: float, dy: float): + """Rotate the camera based on current mouse movement. + + Parameters + ---------- + dx : float + Number of rotation increments around the Z axis, with each increment the size + of :attr:`Camera.rotationdelta`. + dy : float + Number of rotation increments around the X axis, with each increment the size + of :attr:`Camera.rotationdelta`. + + Notes + ----- + Camera rotations are only available if the current view mode + is a perspective view (``camera.renderer.config.viewmode == "perspective"``). + + """ + if self.viewmode == "perspective": + self.rotation += [-self.rotationdelta * dy, 0, -self.rotationdelta * dx] + + def pan(self, dx: float, dy: float): + """Pan the camera based on current mouse movement. + + Parameters + ---------- + dx : float + Number of "pan" increments in the "X" direction of the current view, + with each increment the size of :attr:`Camera.pan_delta`. + dy : float + Number of "pan" increments in the "Y" direction of the current view, + with each increment the size of :attr:`Camera.pan_delta`. + """ + R = Rotation.from_euler_angles(self.rotation) + T = Translation.from_vector([-dx * self.pan_delta * self.scale, dy * self.pan_delta * self.scale, 0]) + M = (R * T).matrix + vector = [M[i][3] for i in range(3)] + self.target += vector + + def zoom(self, steps: float = 1): + """Zoom in or out. + + Parameters + ---------- + steps : float + The number of zoom increments, with each increment the size + of :attr:`compas_viewer.components.renderer.Camera.config.zoomdelta`. + + """ + self.distance -= steps * self.zoomdelta * self.distance + + def projection(self, width: int, height: int) -> list[list[float]]: + """Compute the projection matrix corresponding to the current camera settings. + + Parameters + ---------- + width : int + Width of the viewer. + height : int + Height of the viewer. + + Returns + ------- + list[list[float]] + The transformation matrix as a `numpy` array in column-major order. + + Notes + ----- + The projection matrix transforms the scene from camera coordinates to screen coordinates. + + """ + aspect = width / height + if self.viewmode == "perspective": + P = self.perspective(self.fov, aspect, self.near * self.scale, self.far * self.scale) + else: + left = -self.distance + right = self.distance + bottom = -self.distance / aspect + top = self.distance / aspect + P = self.ortho(left, right, bottom, top, self.near * self.scale, self.far * self.scale) + return list(asfortranarray(P, dtype=float32)) + + def viewworld(self) -> list[list[float]]: + """Compute the view-world matrix corresponding to the current camera settings. + + Returns + ------- + list[list[float]] + The transformation matrix in column-major order. + + Notes + ----- + The view-world matrix transforms the scene from world coordinates to camera coordinates. + + """ + T = Translation.from_vector(self.position) + R = Rotation.from_euler_angles(self.rotation) + W = T * R + return list(asfortranarray(W.inverted(), dtype=float32)) diff --git a/src/compas_viewer/view3d/controller.py b/src/compas_viewer/view3d/controller.py new file mode 100644 index 0000000000..4f7f287c45 --- /dev/null +++ b/src/compas_viewer/view3d/controller.py @@ -0,0 +1,24 @@ +from PySide6.QtCore import QObject, Qt, Signal +from compas_viewer.base import Base + +class View3dSignals(QObject): + rotate = Signal(float) + zoom = Signal(float) + +class View3dController(QObject, Base): + def __init__(self, view3d): + super().__init__() + self.view = view3d + self.signals = View3dSignals() + + # Connect signals to view slots + self.signals.rotate.connect(self.view.rotate) + self.signals.zoom.connect(self.view.zoom) + + def mouseMoveEvent(self, event): + if event.buttons() == Qt.LeftButton: + self.signals.rotate.emit(5) # Rotate by 5 degrees + + def wheelEvent(self, event): + zoom_factor = event.angleDelta().y() / 1200 # Adjust zoom factor based on wheel movement + self.signals.zoom.emit(zoom_factor) diff --git a/src/compas_viewer/view3d/view3d.py b/src/compas_viewer/view3d/view3d.py new file mode 100644 index 0000000000..ab746e72a2 --- /dev/null +++ b/src/compas_viewer/view3d/view3d.py @@ -0,0 +1,178 @@ +import time +from numpy import float32 +from numpy import identity + +from OpenGL import GL +from PySide6 import QtCore +from PySide6.QtOpenGLWidgets import QOpenGLWidget + +from compas.colors import Color +from compas.geometry import transform_points_numpy +from compas_viewer.base import Base +from compas_viewer.components.renderer.shaders import Shader +from compas_viewer.view3d.controller import View3dController +from compas_viewer.view3d.camera import Camera +from compas_viewer.scene.meshobject import MeshObject + + + +class OpenGLWidget(QOpenGLWidget, Base): + def __init__(self) -> None: + super().__init__() + + self.rotation_angle = 0 + self.scale = 1.0 + + self._frames = 0 + self._now = time.time() + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + self.grabGesture(QtCore.Qt.PinchGesture) + self.camera = Camera() + # self.shader = Shader() + + def clear(self): + GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) # type: ignore + + def initializeGL(self): + GL.glClearColor(0.7, 0.7, 0.7, 1.0) + GL.glPolygonOffset(1.0, 1.0) + GL.glEnable(GL.GL_POLYGON_OFFSET_FILL) + GL.glEnable(GL.GL_CULL_FACE) + GL.glCullFace(GL.GL_BACK) + GL.glEnable(GL.GL_DEPTH_TEST) + GL.glDepthFunc(GL.GL_LESS) + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) + GL.glEnable(GL.GL_POINT_SMOOTH) + GL.glEnable(GL.GL_LINE_SMOOTH) + GL.glEnable(GL.GL_FRAMEBUFFER_SRGB) + + for obj in self.viewer.scene.objects: + obj.init() + + # TODO(pitsai): impliment shader + projection = self.camera.projection(self.w, self.h) + viewworld = self.camera.viewworld() + transform = list(identity(4, dtype=float32)) + + self.shader_model = Shader(name="model") + self.shader_model.bind() + self.shader_model.uniform4x4("projection", projection) + self.shader_model.uniform4x4("viewworld", viewworld) + self.shader_model.uniform4x4("transform", transform) + self.shader_model.uniform1i("is_selected", 0) + self.shader_model.uniform1f("opacity", 1) + self.shader_model.uniform3f("selection_color", Color.black()) + self.shader_model.release() + + def paintGL(self, is_instance: bool = False): + pass + + def resizeGL(self, w: int, h: int): + GL.glViewport(0, 0, self.viewer.config.window.width, self.viewer.config.window.height) + # Add rendering code here + + def paint(self): + """ + Paint all the items in the render, which only be called by the paintGL function + and determines the performance of the renders + This function introduces decision tree for different render modes and settings. + It is only called by the :class:`compas_viewer.components.render.Render.paintGL` function. + + See Also + -------- + :func:`compas_viewer.components.render.Render.paintGL` + :func:`compas_viewer.components.render.Render.paint_instance` + """ + + # Matrix update + viewworld = self.camera.viewworld() + self.update_projection() + # Object categorization + mesh_objs = self.viewer.scene.sort_objects_from_category("MeshObject") + + # Draw model objects in the scene + self.shader_model.bind() + self.shader_model.uniform4x4("viewworld", viewworld) + for obj in self.sort_objects_from_viewworld(mesh_objs, viewworld): + obj.draw(self.shader_model, True, False) + self.shader_model.release() + + def update_projection(self, w=None, h=None): + """ + Update the projection matrix. + + Parameters + ---------- + w : int, optional + The width of the renderer, by default None. + h : int, optional + The height of the renderer, by default None. + """ + w = w or self.viewer.config.window.width + h = h or self.viewer.config.window.height + + projection = self.camera.projection(w, h) + self.shader_model.bind() + self.shader_model.uniform4x4("projection", projection) + self.shader_model.release() + + def sort_objects_from_viewworld(self, objects: list["MeshObject"], viewworld: list[list[float]]): + """Sort objects by the distances from their bounding box centers to camera location + + Parameters + ---------- + objects : list[:class:`compas_viewer.scene.meshobject.MeshObject`] + The objects to be sorted. + viewworld : list[list[float]] + The viewworld matrix. + + Returns + ------- + list + A list of sorted objects. + """ + opaque_objects = [] + transparent_objects = [] + centers = [] + + for obj in objects: + if obj.opacity * self.opacity < 1 and obj.bounding_box_center is not None: + transparent_objects.append(obj) + centers.append(transform_points_numpy([obj.bounding_box_center], obj.worldtransformation)[0]) + else: + opaque_objects.append(obj) + if transparent_objects: + centers = transform_points_numpy(centers, viewworld) + transparent_objects = sorted(zip(transparent_objects, centers), key=lambda pair: pair[1][2]) + transparent_objects, _ = zip(*transparent_objects) + return opaque_objects + list(transparent_objects) + + def rotate(self, angle): + self.rotation_angle += angle + self.update() + + def zoom(self, factor): + self.scale += factor + self.update() + + + +class View3D(OpenGLWidget): + def __init__(self): + super().__init__() + self.controller = View3dController(self) + self.installEventFilter(self.controller) + # TODO(pitsai): config + self.w = 1280 + self.h = 720 + self.opacity = 0.8 + self.selector_color = Color.yellow() + self.shader_model = None + + def eventFilter(self, obj, event): + if obj == self.opengl_view and event.type() in [event.MouseMove, event.Wheel]: + # Redirect events to the controller + self.controller.mouseMoveEvent(event) if event.type() == event.MouseMove else self.controller.wheelEvent(event) + return True + return super().eventFilter(obj, event) diff --git a/src/compas_viewer/viewer.py b/src/compas_viewer/viewer.py index 286abcb214..8e85d6d465 100644 --- a/src/compas_viewer/viewer.py +++ b/src/compas_viewer/viewer.py @@ -12,10 +12,11 @@ from compas_viewer.config import Config from compas_viewer.configurations import ControllerConfig from compas_viewer.configurations import RendererConfig -from compas_viewer.controller import Controller +# from compas_viewer.controller import Controller from compas_viewer.scene.scene import ViewerScene from compas_viewer.singleton import Singleton from compas_viewer.ui.ui import UI +from compas_viewer.view3d.view3d import View3D class Viewer(Singleton): @@ -25,9 +26,11 @@ def __init__(self, *args, **kwargs): self.timer = QTimer() self.config = Config() self.scene = ViewerScene() + # TODO(pitsai): combine config file - self.renderer = Renderer(RendererConfig.from_default()) - self.controller = Controller(ControllerConfig.from_default()) + # self.renderer = Renderer(RendererConfig.from_default()) + self.view3d = View3D() + self.ui = UI() def show(self):