From b4c104c7e05811cbebbdebe83f907aa6d3cd0001 Mon Sep 17 00:00:00 2001 From: pitsai Date: Wed, 8 May 2024 18:07:46 +0200 Subject: [PATCH 1/4] init structure --- .../components/renderer/renderer.py | 2 +- src/compas_viewer/ui/ui.py | 4 +- src/compas_viewer/ui/viewport.py | 4 +- src/compas_viewer/view3d/camera.py | 432 ++++++++++++++++++ src/compas_viewer/view3d/view3d.py | 58 +++ 5 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 src/compas_viewer/view3d/camera.py create mode 100644 src/compas_viewer/view3d/view3d.py diff --git a/src/compas_viewer/components/renderer/renderer.py b/src/compas_viewer/components/renderer/renderer.py index faac148c4e..97feedfa74 100644 --- a/src/compas_viewer/components/renderer/renderer.py +++ b/src/compas_viewer/components/renderer/renderer.py @@ -247,7 +247,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/ui/ui.py b/src/compas_viewer/ui/ui.py index 178e17b254..86d7f4775f 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -1,11 +1,11 @@ +from compas_viewer.components.component_manager import ComponentsManager + from .mainwindow import MainWindow from .menubar import MenuBar from .statusbar import SatusBar from .toolbar import ToolBar from .viewport import ViewPort -from compas_viewer.components.component_manager import ComponentsManager - class UI: def __init__(self) -> None: diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index 9cfa7dedc3..79b27d6b80 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -1,7 +1,7 @@ from PySide6 import QtCore from PySide6 import QtWidgets -from compas_viewer.components.component_manager import ComponentsManager +from compas_viewer.view3d.view3d import View3D class SideBarRight: @@ -40,6 +40,6 @@ def setup_view_port(self) -> None: self.sidebar_right.setup_sidebar_right() self.viewport_widget = QtWidgets.QSplitter() - self.viewport_widget.addWidget(self.viewer.renderer) + self.viewport_widget.addWidget(View3D()) self.viewport_widget.addWidget(self.sidebar_right.side_right_widget) 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..7bd85910d0 --- /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 setup_camera(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/view3d.py b/src/compas_viewer/view3d/view3d.py new file mode 100644 index 0000000000..bea64c72f1 --- /dev/null +++ b/src/compas_viewer/view3d/view3d.py @@ -0,0 +1,58 @@ +from OpenGL import GL +from PySide6.QtOpenGLWidgets import QOpenGLWidget + +from compas_viewer.view3d.camera import Camera + + +class BaseOpenGLWidget(QOpenGLWidget): + def __init__(self) -> None: + super().__init__() + + 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) + + def resizeGL(self, w: int, h: int): + GL.glViewport(0, 0, w, h) + # Add rendering code here + + +class InteractiveOpenGLWidget(BaseOpenGLWidget): + def __init__(self): + super().__init__() + + def mousePressEvent(self, event): + print(f"Mouse pressed at {event.position()}") + + def mouseMoveEvent(self, event): + print(f"Mouse moved to {event.position()}") + + +class View3D(InteractiveOpenGLWidget): + def __init__(self): + super().__init__() + + self.camera = Camera() + # TODO(pitsai): impliment shader + # self.shader_model = Shader(name="model") + + def paintGL(self): + super().paintGL() + self.renderCustomScene() + + def renderCustomScene(self): + # Placeholder for custom rendering logic + pass From 7ac00629aa0ebe565fbf205d30d664aa0709c8d3 Mon Sep 17 00:00:00 2001 From: pitsai Date: Thu, 9 May 2024 01:04:02 +0200 Subject: [PATCH 2/4] view3d structure --- .../components/renderer/camera.py | 17 +- src/compas_viewer/scene/scene.py | 21 ++- src/compas_viewer/ui/ui.py | 1 - src/compas_viewer/ui/viewport.py | 7 +- src/compas_viewer/view3d/view3d.py | 158 +++++++++++++++++- src/compas_viewer/viewer.py | 2 +- 6 files changed, 183 insertions(+), 23 deletions(-) diff --git a/src/compas_viewer/components/renderer/camera.py b/src/compas_viewer/components/renderer/camera.py index 8085cc090e..43f1d69288 100644 --- a/src/compas_viewer/components/renderer/camera.py +++ b/src/compas_viewer/components/renderer/camera.py @@ -326,14 +326,15 @@ def _on_target_update(self, target: Position): def reset_position(self): """Reset the position of the camera based current view type.""" self.target.set(0, 0, 0, False) - if self.viewer.renderer.viewmode == "perspective": - self.rotation.set(pi / 4, 0, -pi / 4, False) - if self.viewer.renderer.viewmode == "top": - self.rotation.set(0, 0, 0, False) - if self.viewer.renderer.viewmode == "front": - self.rotation.set(pi / 2, 0, 0, False) - if self.viewer.renderer.viewmode == "right": - self.rotation.set(pi / 2, 0, pi / 2, False) + self.rotation.set(pi / 4, 0, -pi / 4, False) + # if self.viewer.renderer.viewmode == "perspective": + # self.rotation.set(pi / 4, 0, -pi / 4, False) + # if self.viewer.renderer.viewmode == "top": + # self.rotation.set(0, 0, 0, False) + # if self.viewer.renderer.viewmode == "front": + # self.rotation.set(pi / 2, 0, 0, False) + # if self.viewer.renderer.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. diff --git a/src/compas_viewer/scene/scene.py b/src/compas_viewer/scene/scene.py index 40cf4d4755..a9653b89e8 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: @@ -187,3 +189,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/ui.py b/src/compas_viewer/ui/ui.py index 68002dee87..edfe5319ab 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -28,7 +28,6 @@ def lazy_init(self): self.statusbar.lazy_init() self.toolbar.lazy_init() self.viewport.lazy_init() - self.viewer.renderer.camera.lazy_init() def show(self): self.window.show() diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index 1b9dae66d1..9c59157f53 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -13,7 +13,7 @@ def __init__(self) -> None: self.custom_widgets: list[str] = [] # TODO(pitsai): self.viewer.config.ui.sidebar.items self.widget_list: list = self.default_widgets + self.custom_widgets - 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.viewer.ui.components_manager.add_widgets(self.widget_list) @@ -23,12 +23,13 @@ def setup_sidebar_right(self) -> None: class ViewPort(Base): def __init__(self): + self.view3d = View3D() 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(View3D()) + self.viewport_widget.addWidget(self.view3d) self.viewport_widget.addWidget(self.sidebar_right.side_right_widget) self.viewer.ui.window.centralWidget().layout().addWidget(self.viewport_widget) diff --git a/src/compas_viewer/view3d/view3d.py b/src/compas_viewer/view3d/view3d.py index bea64c72f1..c34ef53161 100644 --- a/src/compas_viewer/view3d/view3d.py +++ b/src/compas_viewer/view3d/view3d.py @@ -1,10 +1,18 @@ +from numpy import float32 +from numpy import identity + from OpenGL import GL from PySide6.QtOpenGLWidgets import QOpenGLWidget +from PySide6.QtGui import QMouseEvent +from PySide6.QtGui import QWheelEvent +from compas_viewer.base import Base +from compas.colors import Color from compas_viewer.view3d.camera import Camera +from compas_viewer.components.renderer.shaders import Shader -class BaseOpenGLWidget(QOpenGLWidget): +class BaseOpenGLWidget(QOpenGLWidget, Base): def __init__(self) -> None: super().__init__() @@ -26,7 +34,7 @@ def initializeGL(self): GL.glEnable(GL.GL_FRAMEBUFFER_SRGB) def resizeGL(self, w: int, h: int): - GL.glViewport(0, 0, w, h) + GL.glViewport(0, 0, 1280, 720) # Add rendering code here @@ -34,25 +42,157 @@ class InteractiveOpenGLWidget(BaseOpenGLWidget): def __init__(self): super().__init__() - def mousePressEvent(self, event): - print(f"Mouse pressed at {event.position()}") + # TODO(pitsai): + # Controller() + # Selector() + + def mousePressEvent(self, event: QMouseEvent): + """ + Callback for the mouse press event which passes the event to the controller. + + Parameters + ---------- + event : :PySide6:`PySide6/QtGui/QMouseEvent` + The Qt event. + + See Also + -------- + :func:`compas_viewer.controller.Controller.mouse_press_action` + + References + ---------- + * https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.mousePressEvent + + """ + if self.isActiveWindow() and self.underMouse(): + self.viewer.controller.mouse_press_action(event) + self.update() + + def mouseReleaseEvent(self, event: QMouseEvent): + """ + Callback for the release press event which passes the event to the controller. + + Parameters + ---------- + event : :PySide6:`PySide6/QtGui/QMouseEvent` + The Qt event. + + See Also + -------- + :func:`compas_viewer.controller.Controller.mouse_release_action` + + References + ---------- + * https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.mouseReleaseEvent + + """ + if self.isActiveWindow() and self.underMouse(): + self.viewer.controller.mouse_release_action(event) + self.update() + + def wheelEvent(self, event: QWheelEvent): + """ + Callback for the mouse wheel event which passes the event to the controller. + + Parameters + ---------- + event : :PySide6:`PySide6/QtGui/QWheelEvent` + The Qt event. + + See Also + -------- + :func:`compas_viewer.controller.Controller.wheel_action` + + References + ---------- + * https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.wheelEvent + + """ + if self.isActiveWindow() and self.underMouse(): + self.viewer.controller.wheel_action(event) + self.update() def mouseMoveEvent(self, event): - print(f"Mouse moved to {event.position()}") + # print(f"Mouse moved to {event.position()}") + pass class View3D(InteractiveOpenGLWidget): def __init__(self): super().__init__() + # TODO(pitsai): config + self.w = 1280 + self.h = 720 + self.opacity = 0.8 + self.selector_color = Color.yellow() + self.shader_model = None + self.camera = Camera() - # TODO(pitsai): impliment shader - # self.shader_model = Shader(name="model") + + def lazy_init(self): + self.camera.lazy_init() + for obj in self.viewer.scene.objects: + obj.init() def paintGL(self): + self.clear() super().paintGL() self.renderCustomScene() def renderCustomScene(self): - # Placeholder for custom rendering logic - pass + """ + 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` + """ + # 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", self.opacity) + self.shader_model.uniform3f("selection_color", self.selector_color) + self.shader_model.release() + + # Matrix update + viewworld = self.camera.viewworld() + self.update_projection() + # Object categorization + mesh_objs = self.viewer.scene.sort_objects_from_category("MeshObject") + # tag_objs, vector_objs, mesh_objs = self.sort_objects_from_category((obj for obj in self.viewer.scene.objects if obj.is_visible)) + + # Draw model objects in the scene + self.shader_model.bind() + for obj in mesh_objs: + 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. + """ + + projection = self.camera.projection(self.w, self.h) + self.shader_model.bind() + self.shader_model.uniform4x4("projection", projection) + self.shader_model.release() diff --git a/src/compas_viewer/viewer.py b/src/compas_viewer/viewer.py index 6b3d011d71..a42110e6c2 100644 --- a/src/compas_viewer/viewer.py +++ b/src/compas_viewer/viewer.py @@ -18,7 +18,7 @@ def __init__(self, *args, **kwargs): self.config = Config() self.scene = ViewerScene() # TODO(pitsai): combine config file - self.renderer = Renderer(RendererConfig.from_default()) + # self.renderer = Renderer(RendererConfig.from_default()) self.controller = Controller(ControllerConfig.from_default()) self.ui = UI() From ec8972fa764414438b3ec402773b92f1efb96de2 Mon Sep 17 00:00:00 2001 From: pitsai Date: Thu, 9 May 2024 01:37:41 +0200 Subject: [PATCH 3/4] one time init shader --- src/compas_viewer/view3d/camera.py | 6 +++--- src/compas_viewer/view3d/view3d.py | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/compas_viewer/view3d/camera.py b/src/compas_viewer/view3d/camera.py index 7bd85910d0..aa1d741f22 100644 --- a/src/compas_viewer/view3d/camera.py +++ b/src/compas_viewer/view3d/camera.py @@ -152,11 +152,11 @@ def __init__( self._target.pause_update = False self.target = Position(init_target) - def setup_camera(self) -> None: + 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) + # if self.viewmode == "perspective": + # self.position = Position(self.config.position) @property def position(self) -> Position: diff --git a/src/compas_viewer/view3d/view3d.py b/src/compas_viewer/view3d/view3d.py index c34ef53161..c0a01f670e 100644 --- a/src/compas_viewer/view3d/view3d.py +++ b/src/compas_viewer/view3d/view3d.py @@ -131,16 +131,6 @@ def __init__(self): self.camera = Camera() def lazy_init(self): - self.camera.lazy_init() - for obj in self.viewer.scene.objects: - obj.init() - - def paintGL(self): - self.clear() - super().paintGL() - self.renderCustomScene() - - def renderCustomScene(self): """ Paint all the items in the render, which only be called by the paintGL function and determines the performance of the renders @@ -152,6 +142,12 @@ def renderCustomScene(self): :func:`compas_viewer.components.render.Render.paintGL` :func:`compas_viewer.components.render.Render.paint_instance` """ + + self.camera.lazy_init() + + 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() @@ -180,6 +176,10 @@ def renderCustomScene(self): obj.draw(self.shader_model, True, False) self.shader_model.release() + def paintGL(self): + self.clear() + super().paintGL() + def update_projection(self, w=None, h=None): """ Update the projection matrix. @@ -191,7 +191,7 @@ def update_projection(self, w=None, h=None): h : int, optional The height of the renderer, by default None. """ - + # TODO(pitsai): recalculating is only performed when the viewport size changes. projection = self.camera.projection(self.w, self.h) self.shader_model.bind() self.shader_model.uniform4x4("projection", projection) From 2fa591945a2fae1585f78d9cf2095166ab1534d1 Mon Sep 17 00:00:00 2001 From: pitsai Date: Tue, 14 May 2024 15:53:15 +0200 Subject: [PATCH 4/4] view3d --- .../components/renderer/camera.py | 11 +- src/compas_viewer/ui/viewport.py | 3 +- src/compas_viewer/view3d/controller.py | 24 ++ src/compas_viewer/view3d/view3d.py | 232 ++++++++---------- src/compas_viewer/viewer.py | 7 +- 5 files changed, 142 insertions(+), 135 deletions(-) create mode 100644 src/compas_viewer/view3d/controller.py diff --git a/src/compas_viewer/components/renderer/camera.py b/src/compas_viewer/components/renderer/camera.py index 43f1d69288..e018d46d42 100644 --- a/src/compas_viewer/components/renderer/camera.py +++ b/src/compas_viewer/components/renderer/camera.py @@ -155,12 +155,13 @@ def __init__( 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.renderer.config.viewmode == "perspective": - # self.position = Position(self.config.position) + + # def lazy_init(self) -> None: + # # Camera position only modifiable in perspective view mode. + # self.reset_position() + # # if self.renderer.config.viewmode == "perspective": + # # self.position = Position(self.config.position) @property def position(self) -> Position: diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index 9c59157f53..7a88d5aae5 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -23,13 +23,12 @@ def lazy_init(self) -> None: class ViewPort(Base): def __init__(self): - self.view3d = View3D() self.sidebar_right = SideBarRight() def lazy_init(self) -> None: self.sidebar_right.lazy_init() self.viewport_widget = QtWidgets.QSplitter() - self.viewport_widget.addWidget(self.view3d) + self.viewport_widget.addWidget(self.viewer.view3d) self.viewport_widget.addWidget(self.sidebar_right.side_right_widget) self.viewer.ui.window.centralWidget().layout().addWidget(self.viewport_widget) 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 index c0a01f670e..ab746e72a2 100644 --- a/src/compas_viewer/view3d/view3d.py +++ b/src/compas_viewer/view3d/view3d.py @@ -1,21 +1,35 @@ +import time from numpy import float32 from numpy import identity from OpenGL import GL +from PySide6 import QtCore from PySide6.QtOpenGLWidgets import QOpenGLWidget -from PySide6.QtGui import QMouseEvent -from PySide6.QtGui import QWheelEvent -from compas_viewer.base import Base from compas.colors import Color -from compas_viewer.view3d.camera import Camera +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 BaseOpenGLWidget(QOpenGLWidget, Base): + +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 @@ -32,105 +46,33 @@ def initializeGL(self): 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() - def resizeGL(self, w: int, h: int): - GL.glViewport(0, 0, 1280, 720) - # Add rendering code here - - -class InteractiveOpenGLWidget(BaseOpenGLWidget): - def __init__(self): - super().__init__() - - # TODO(pitsai): - # Controller() - # Selector() - - def mousePressEvent(self, event: QMouseEvent): - """ - Callback for the mouse press event which passes the event to the controller. - - Parameters - ---------- - event : :PySide6:`PySide6/QtGui/QMouseEvent` - The Qt event. - - See Also - -------- - :func:`compas_viewer.controller.Controller.mouse_press_action` - - References - ---------- - * https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.mousePressEvent - - """ - if self.isActiveWindow() and self.underMouse(): - self.viewer.controller.mouse_press_action(event) - self.update() - - def mouseReleaseEvent(self, event: QMouseEvent): - """ - Callback for the release press event which passes the event to the controller. - - Parameters - ---------- - event : :PySide6:`PySide6/QtGui/QMouseEvent` - The Qt event. - - See Also - -------- - :func:`compas_viewer.controller.Controller.mouse_release_action` - - References - ---------- - * https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.mouseReleaseEvent - - """ - if self.isActiveWindow() and self.underMouse(): - self.viewer.controller.mouse_release_action(event) - self.update() - - def wheelEvent(self, event: QWheelEvent): - """ - Callback for the mouse wheel event which passes the event to the controller. - - Parameters - ---------- - event : :PySide6:`PySide6/QtGui/QWheelEvent` - The Qt event. - - See Also - -------- - :func:`compas_viewer.controller.Controller.wheel_action` - - References - ---------- - * https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.wheelEvent + # TODO(pitsai): impliment shader + projection = self.camera.projection(self.w, self.h) + viewworld = self.camera.viewworld() + transform = list(identity(4, dtype=float32)) - """ - if self.isActiveWindow() and self.underMouse(): - self.viewer.controller.wheel_action(event) - self.update() + 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 mouseMoveEvent(self, event): - # print(f"Mouse moved to {event.position()}") + 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 -class View3D(InteractiveOpenGLWidget): - def __init__(self): - super().__init__() - - # TODO(pitsai): config - self.w = 1280 - self.h = 720 - self.opacity = 0.8 - self.selector_color = Color.yellow() - self.shader_model = None - - self.camera = Camera() - - def lazy_init(self): + 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 @@ -143,43 +85,19 @@ def lazy_init(self): :func:`compas_viewer.components.render.Render.paint_instance` """ - self.camera.lazy_init() - - 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", self.opacity) - self.shader_model.uniform3f("selection_color", self.selector_color) - self.shader_model.release() - # Matrix update viewworld = self.camera.viewworld() self.update_projection() # Object categorization mesh_objs = self.viewer.scene.sort_objects_from_category("MeshObject") - # tag_objs, vector_objs, mesh_objs = self.sort_objects_from_category((obj for obj in self.viewer.scene.objects if obj.is_visible)) # Draw model objects in the scene self.shader_model.bind() - for obj in mesh_objs: + 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 paintGL(self): - self.clear() - super().paintGL() - def update_projection(self, w=None, h=None): """ Update the projection matrix. @@ -191,8 +109,70 @@ def update_projection(self, w=None, h=None): h : int, optional The height of the renderer, by default None. """ - # TODO(pitsai): recalculating is only performed when the viewport size changes. - projection = self.camera.projection(self.w, self.h) + 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 a42110e6c2..14264e450b 100644 --- a/src/compas_viewer/viewer.py +++ b/src/compas_viewer/viewer.py @@ -6,10 +6,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): @@ -17,9 +18,11 @@ def __init__(self, *args, **kwargs): self.app = QApplication(sys.argv) self.config = Config() self.scene = ViewerScene() + # TODO(pitsai): combine config file # self.renderer = Renderer(RendererConfig.from_default()) - self.controller = Controller(ControllerConfig.from_default()) + self.view3d = View3D() + self.ui = UI() def show(self):