From ee5a6fbc8423392360c62338c8357ba22f2f9f96 Mon Sep 17 00:00:00 2001 From: Sietze van Buuren Date: Sun, 3 Nov 2024 11:26:01 +0100 Subject: [PATCH] feat: Add 3D axis grid with automatic tick labels to surf plots This is a major update, that introduces a 3D grid with backfaces and tick labels at nice positions. The different grid sides and tick(s) (labels) are automatically shown and hirdden depending on the viewing angle. This update requires a local modification to the GLTextItems to support alignment. Signed-off-by: Sietze van Buuren --- examples/surface.py | 4 +- mlpyqtgraph/axes.py | 72 ++-- mlpyqtgraph/grid_axes.py | 572 ++++++++++++++++++++++++++++++++ mlpyqtgraph/utils/GLTextItem.py | 81 +++++ mlpyqtgraph/utils/ticklabels.py | 73 ++++ mlpyqtgraph/windows.py | 35 +- 6 files changed, 767 insertions(+), 70 deletions(-) create mode 100644 mlpyqtgraph/grid_axes.py create mode 100644 mlpyqtgraph/utils/GLTextItem.py create mode 100644 mlpyqtgraph/utils/ticklabels.py diff --git a/examples/surface.py b/examples/surface.py index cdd833f..7ec0ecf 100644 --- a/examples/surface.py +++ b/examples/surface.py @@ -22,10 +22,10 @@ def main(): d = np.hypot(x, yi) z[:,i] = amplitude * np.cos(frequency*d) / (d+1) - mpg.figure(title='Perspective surface plot') + mpg.figure(title='Perspective surface plot', layout_type='Qt') mpg.surf(x, y, z, colormap='viridis', projection='perspective') - mpg.figure(title='Orthographic surface plot') + mpg.figure(title='Orthographic surface plot', layout_type='Qt') mpg.surf(x, y, z, colormap='viridis', projection='orthographic') diff --git a/mlpyqtgraph/axes.py b/mlpyqtgraph/axes.py index 7ca4ab5..7246729 100644 --- a/mlpyqtgraph/axes.py +++ b/mlpyqtgraph/axes.py @@ -1,6 +1,4 @@ -""" -mlpyqtgraph axes module, with 2D and 3D Axis classes -""" +""" mlpyqtgraph axes module, with 2D and 3D Axis classes """ import math @@ -13,6 +11,8 @@ import mlpyqtgraph.config_options as config from mlpyqtgraph import colors +from mlpyqtgraph.grid_axes import GLGridAxis +from mlpyqtgraph.utils.ticklabels import coord_generator, limit_generator class RootException(Exception): @@ -133,7 +133,7 @@ def add(self, x_coord, y_coord, **kwargs): self.plot(x_coord, y_coord, pen=line_pen, symbol=symbol, symbolSize=symbol_size, symbolPen=symbol_pen, symbolBrush=symbol_color) - + @property def grid(self): """ Returns grid activation state """ @@ -272,7 +272,7 @@ def delete(self): """ Closes the axis """ -class Axis3D(gl.GLViewWidget): +class Axis3D(gl.GLGraphicsItem.GLGraphicsItem): """ 3D axis """ axis_type = '3D' @@ -311,40 +311,16 @@ class Axis3D(gl.GLViewWidget): 'glOptions': glOption_lines, } - def __init__(self, index, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, index, parentItem=None, **kwargs): + super().__init__(parentItem=parentItem, **kwargs) self.index = index - self.setup() + self.grid_axes = GLGridAxis(parentItem=self) + #self.view().setCameraPosition(**self.grid_axes.best_camera()) - def setup(self): - """ Sets up the 3D iew widget """ - self.setCameraPosition(distance=40) - - rotations = ( - (90, 0, 1, 0), - (90, 1, 0, 0), - ( 0, 0, 0, 0), - ) - translations = ( - (-10, 0, 0), - ( 0, -10, 0), - ( 0, 0, -10) - ) - - for rot, trans in zip(rotations, translations): - grid = gl.GLGridItem() - grid.setColor(0.0) - grid.rotate(*rot) - grid.translate(*trans) - grid.setDepthValue(10) # draw grid after surfaces since they may be translucent - self.addItem(grid) - - #grid = gl.GLGridItem() - #grid.setColor('k') - #grid.scale(1, 1, 1) - #grid.setSize(x=1) - #grid.setDepthValue(10) # draw grid after surfaces since they may be translucent - #self.addItem(grid) + def _setView(self, v): + super()._setView(v) + for child in self.childItems(): + child._setView(v) @staticmethod def set_colormap(surface, colormap='CET-L10'): @@ -356,22 +332,35 @@ def set_colormap(surface, colormap='CET-L10'): def set_projection_method(self, *coords, method='orthographic'): """ Sets the projection method, either perspective or orthographic """ - #object_size = math.sqrt(sum([coord.ptp()**2.0 for coord in coords])) object_size = (sum([np.ptp(coord)**3.0 for coord in coords]))**(1.0/3.0) field_of_view = 60 if method == 'orthographic': field_of_view = 1 distance = 0.75*object_size/math.tan(0.5*field_of_view/180.0*math.pi) - self.setCameraParams(fov=field_of_view, distance=distance) + self.view().setCameraParams(fov=field_of_view, distance=distance) def add(self, *args, **kwargs): """ Adds a 3D surface plot item to the view widget """ kwargs = dict(self.default_surface_options, **kwargs) surface = gl.GLSurfacePlotItem(*args, **kwargs) + self.view().addItem(surface) self.set_colormap(surface, colormap=kwargs['colormap']) self.set_projection_method(*args, method=kwargs['projection']) - self.addItem(surface) self.add_grid_lines(*args) + self.update_grid_axes(*args, **kwargs) + + def calculate_ax_coord_lims(self, x, y, z): + """ Calculates the axis coordinates limits """ + coords = dict(coord_generator(num_ticks=6, x=x, y=y, z=z)) + limits = dict(limit_generator(limit_ratio=0.05, **coords)) + return coords, limits + + def update_grid_axes(self, *args, **kwargs): + """ Plots the grid axes """ + coords, limits = self.calculate_ax_coord_lims(*args) + self.grid_axes.setData(coords=coords, limits=limits) + projection_method = kwargs.get('projection', 'perspective') + self.view().setCameraPosition(**self.grid_axes.best_camera(method=projection_method)) def add_grid_lines(self, *args): """ Plots all grid lines """ @@ -385,7 +374,8 @@ def add_grid_lines(self, *args): def add_single_grid_line(self, x, y, z): """ Plots a single grid line for given coordinates """ points = np.column_stack((x, y, z)) - self.addItem(gl.GLLinePlotItem(pos=points, **self.default_line_options)) + line = gl.GLLinePlotItem(pos=points, **self.default_line_options) + self.view().addItem(line) def delete(self): """ Closes the axis """ diff --git a/mlpyqtgraph/grid_axes.py b/mlpyqtgraph/grid_axes.py new file mode 100644 index 0000000..469b3c1 --- /dev/null +++ b/mlpyqtgraph/grid_axes.py @@ -0,0 +1,572 @@ +""" 3D GridAxis classes """ + +import numpy as np +from pyqtgraph import QtGui, QtCore +import pyqtgraph as pg +from pyqtgraph.opengl import GLGraphicsItem, GLLinePlotItem, GLMeshItem +import OpenGL.GL as ogl +from mlpyqtgraph.utils.GLTextItem import GLTextItem +from collections import namedtuple + + +pg.setConfigOption('background', 'w') +pg.setConfigOption('foreground', 'k') +pg.setConfigOptions(antialias=True) + + +class GLGridAxisBase(GLGraphicsItem.GLGraphicsItem): + """ Base class for OpenGL classes """ + + def orphan_children(self): + for child_item in self.childItems(): + child_item.setParentItem(None) + + +class GLGridAxisItemBase(GLGridAxisBase): + """ Base class for OpenGL classes """ + + + glOption = { + ogl.GL_DEPTH_TEST: True, + ogl.GL_BLEND: True, + ogl.GL_ALPHA_TEST: False, + ogl.GL_CULL_FACE: False, + ogl.GL_LINE_SMOOTH: True, + 'glHint': (ogl.GL_LINE_SMOOTH_HINT, ogl.GL_NICEST), + 'glBlendFunc': (ogl.GL_SRC_ALPHA, ogl.GL_ONE_MINUS_SRC_ALPHA), + } + + glOption_surface = { + **glOption, + ogl.GL_POLYGON_OFFSET_FILL: True, + 'glPolygonOffset': (1.0, 1.0 ), + } + + glOption_lines = { + **glOption, + ogl.GL_POLYGON_OFFSET_FILL: False, + } + + default_surface_options = { + 'glOptions': glOption_surface, + 'color': (0.95, 0.95, 0.95, 1), + 'smooth': True, + 'projection': 'perspective', + } + + default_line_options = { + 'color': (0, 0, 0, 1), + 'antialias': True, + 'width': 1, + 'glOptions': glOption_lines, + } + + +class GLGridPlane(GLGridAxisItemBase): + """ Represents a grid plane """ + + default_line_options = { + **GLGridAxisItemBase.default_line_options, + 'color': (0.7, 0.7, 0.7, 1), + } + + def __init__(self, parentItem=None, **kwargs): + super().__init__(parentItem=parentItem) + self.items = [] + self.plane = 'x' + self.offset = 0.0 + self.coords = (0, 1), (0, 1) + self.limits = (-0.05, 1.05), (-0.05, 1.05) + self.setData(**kwargs) + + def setData(self, **kwargs): + """ + Update the grid plane + + ==================== ================================================== + **Arguments:** + ------------------------------------------------------------------------ + plane 'x', 'y', or 'z', specifies in which plane the + grid lies + offset the offset along the axis orthogonal to the plane + coords tuples with the coordinates for the first and + second axis and the grid + limits tuples with the limits for the first and + second axis and the grid + ==================== ================================================== + """ + args = ('plane', 'offset', 'coords', 'limits') + for k in kwargs.keys(): + if k not in args: + raise ValueError(f'Invalid keyword argument: {k} (allowed arguments are {args})') + for key, value in kwargs.items(): + setattr(self, key, value) + self.orphan_children() + self.items = list(self.grid_generator()) + self.update() + + def grid_generator(self): + """ Yield grid plane items """ + vertices, faces = self.backplane_face() + yield GLMeshItem( + parentItem=self, + vertexes=vertices, + faces=faces, + **self.default_surface_options + ) + for pos in self.grid_positions(): + yield GLLinePlotItem( + parentItem=self, + pos=pos, + **self.default_line_options + ) + + def grid_positions(self): + """Create a grid positions in the specified plane. + + Parameters: + - plane: 'x', 'y', or 'z', specifies in which plane the grid lies. + - offset: the offset along the axis orthogonal to the plane. + - coord1: coordinates for the first axis of the grid. + - coord2: coordinates for the second axis of the grid. + """ + coord1, coord2 = self.coords + lim1, lim2 = self.limits + plane, offset = self.plane, self.offset + if plane == 'x': + for y in coord1: + yield np.array([[offset, y, lim2[0]], [offset, y, lim2[1]]]) + for z in coord2: + yield np.array([[offset, lim1[0], z], [offset, lim1[1], z]]) + elif plane == 'y': + for x in coord1: + yield np.array([[x, offset, lim2[0]], [x, offset, lim2[1]]]) + for z in coord2: + yield np.array([[lim1[0], offset, z], [lim1[1], offset, z]]) + elif plane == 'z': + for x in coord1: + yield np.array([[x, lim2[0], offset], [x, lim2[1], offset]]) + for y in coord2: + yield np.array([[lim1[0], y, offset], [lim1[1], y, offset]]) + + def backplane_face(self): + """Create a backplane face in the specified plane. + + Parameters: + - plane: 'x', 'y', or 'z', specifies in which plane the grid lies. + - offset: the offset along the axis orthogonal to the plane. + - coord1: coordinates for the first axis of the grid. + - coord2: coordinates for the second axis of the grid. + """ + plane = self.plane + offset = self.offset + lim1, lim2 = self.limits + if plane == 'x': + vertices = np.array([ + [offset, lim1[0], lim2[0]], + [offset, lim1[1], lim2[0]], + [offset, lim1[1], lim2[1]], + [offset, lim1[0], lim2[1]], + ]) + faces = np.array([[0, 1, 2], [0, 2, 3]]) + return vertices, faces + if plane == 'y': + vertices = np.array([ + [lim1[0], offset, lim2[0]], + [lim1[1], offset, lim2[0]], + [lim1[1], offset, lim2[1]], + [lim1[0], offset, lim2[1]], + ]) + faces = np.array([[0, 1, 2], [0, 2, 3]]) + return vertices, faces + if plane == 'z': + vertices = np.array([ + [lim1[0], lim2[0], offset], + [lim1[1], lim2[0], offset], + [lim1[1], lim2[1], offset], + [lim1[0], lim2[1], offset], + ]) + faces = np.array([[0, 1, 2], [0, 2, 3]]) + return vertices, faces + raise ValueError('Invalid plane') + + +class GLAxis(GLGridAxisItemBase): + """ Hold labels for an axis """ + offset_map = { + 'xm': [0, -1, 0], + 'xp': [0, +1, 0], + 'ym': [-1, 0, 0], + 'yp': [+1, 0, 0], + 'zrmm': [0, -1, 0], + 'zrmp': [-1, 0, 0], + 'zrpm': [+1, 0, 0], + 'zrpp': [0, +1, 0], + 'zlmm': [+1, 0, 0], + 'zlmp': [0, -1, 0], + 'zlpm': [0, +1, 0], + 'zlpp': [-1, 0, 0] + + } + left_alignment = QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + right_alignment = QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + + def __init__(self, parentItem=None, **kwargs): + super().__init__(parentItem=parentItem) + self.labels = [] + self.lines = [] + self.coords = (0, 1), (0, 1) + self.ax_limits = (-0.05, 1.05) + self.limits = (-0.05, 1.05), (-0.05, 1.05) + self.axis = 'xm' + self.font = QtGui.QFont('Helvetica', 10) + self.color = (0, 0, 0, 255) + self._offset = 0.022 + self._is_bottom = True + self.setData(**kwargs) + + def _setView(self, v): + super()._setView(v) + for child in self.childItems(): + child._setView(v) + + def setData(self, **kwargs): + """ Update the axis labels """ + args = ('coords', 'ax_limits', 'limits', 'axis', 'font', 'color') + for k in kwargs.keys(): + if k not in args: + raise ValueError(f'Invalid keyword argument: {k} (allowed arguments are {args})') + for key, value in kwargs.items(): + setattr(self, key, value) + self.orphan_children() + self._offset = self.calculate_tick_offset() + self.labels = list(self.create_labels()) + self.lines = list(self.create_lines()) + self.update() + + def calculate_tick_offset(self): + """ Calculate the tick offset """ + lim1, lim2 = self.limits + return 0.02*(np.diff(self.ax_limits) + + np.diff(lim1) + + np.diff(lim2)) + + def alignments(self): + """ Define the grid points in the plain defined by axis=offset """ + if self.axis.startswith('x'): + if self.axis.endswith('mp') or self.axis.endswith('pm'): + return self.right_alignment + return self.left_alignment + + if self.axis.startswith('y'): + if self.axis.endswith('mp') or self.axis.endswith('pm'): + return self.left_alignment + return self.right_alignment + + if self.axis.startswith('zr'): + return self.left_alignment + + if self.axis.startswith('zl'): + return self.right_alignment + + raise ValueError('Invalid axis') + + def get_axis_map(self, coord): + lim1, lim2 = self.limits + axis_map = { + 'xm': [coord, lim1[0], lim2[0]], + 'xp': [coord, lim1[1], lim2[0]], + 'ym': [lim1[0], coord, lim2[0]], + 'yp': [lim1[1], coord, lim2[0]], + 'zrmm': [lim1[0], lim2[0], coord], + 'zrmp': [lim1[0], lim2[1], coord], + 'zrpm': [lim1[1], lim2[0], coord], + 'zrpp': [lim1[1], lim2[1], coord], + 'zlmm': [lim1[1], lim2[1], coord], + 'zlmp': [lim1[1], lim2[0], coord], + 'zlpm': [lim1[0], lim2[1], coord], + 'zlpp': [lim1[0], lim2[0], coord], + + } + return axis_map + + def tick_coordinates(self, coord): + """ Define the grid points in the plain defined by axis=offset """ + axis_map = self.get_axis_map(coord) + try: + base = axis_map[self.axis] + delta = self._offset*self.offset_map[self.axis] + except KeyError: + base = axis_map[self.axis[:2]] + delta = self._offset*self.offset_map[self.axis[:2]] + return np.array([base, np.add(base, delta)]) + + def tick_axis_coordinates(self): + """ Define the grid points in the plain defined by axis=offset """ + start, end = self.ax_limits + try: + start_coords = self.get_axis_map(start)[self.axis] + end_coords = self.get_axis_map(end)[self.axis] + except KeyError: + start_coords = self.get_axis_map(start)[self.axis[:2]] + end_coords = self.get_axis_map(end)[self.axis[:2]] + except KeyError: + raise ValueError('Invalid axis') + + return np.array([start_coords, end_coords]) + + def create_lines(self): + """Yield axis and tick lines""" + for tick_coord in self.coords: + pos = self.tick_coordinates(tick_coord) + pos[1] = pos[1] - 0.5*(pos[1] - pos[0]) + yield GLLinePlotItem( + parentItem=self, + pos=pos, **self.default_line_options + ) + axis_pos = self.tick_axis_coordinates() + yield GLLinePlotItem( + parentItem=self, + pos=axis_pos, **self.default_line_options + ) + + def create_labels(self): + """Yields the axis labels""" + alignment = self.alignments() + for tick_coord in self.coords: + text = f'{tick_coord:.1f}' + pos = self.tick_coordinates(tick_coord) + yield GLTextItem( + parentItem=self, + pos=pos[1], + text=text, + color=self.color, + font=self.font, + alignment=alignment, + ) + + def move_axis_z(self, position): + """ Move the axis to a given z-position """ + for label in self.labels: + pos = list(label.pos) + label.setData(pos=pos[:2] + [position, ]) + for line in self.lines: + pos = list(line.pos) + for posi in pos: + posi[2] = position + line.setData(pos=pos) + + + def move_up(self): + """ Move the labels up """ + if not self._is_bottom: + return + self.move_axis_z(self.limits[1][1]) + self._is_bottom = False + + def move_down(self): + """ Move the labels down """ + if self._is_bottom: + return + self.move_axis_z(self.limits[1][0]) + self._is_bottom = True + + +class GLGridAxis(GLGridAxisBase): + """ Draw a grid with axes, ticks and labels in 3D space for given + coordinates and limits """ + + GridPlaneParams = namedtuple( + 'GridPlaneParams', ['plane', 'side', 'coord1', 'coord2'] + ) + AxisParams = namedtuple( + 'GridPlaneParams', ['axis', 'edge', 'coord1', 'coord2'] + ) + + def __init__(self, parentItem=None, **kwargs): + super().__init__(parentItem=parentItem) + self.grid = {} + self.axes = {} + self.coords = { + 'x': [-1.0, 0.0, 1.0], + 'y': [-1.0, 0.0, 1.0], + 'z': [-1.0, 0.0, 1.0], + } + self.limits = { + 'x': (-1.05, 1.05), + 'y': (-1.05, 1.05), + 'z': (-1.05, 1.05), + } + self.bounding_box_min = np.array([-0.05, -0.05, -0.05]) + self.bounding_box_max = np.array([1.05, 1.05, 1.05]) + self.setData(**kwargs) + + def set_bounding_box_corners(self): + self.bounding_box_min = np.array( + [self.limits['x'][0], self.limits['y'][0], self.limits['z'][0]] + ) + self.bounding_box_max = np.array( + [self.limits['x'][1], self.limits['y'][1], self.limits['z'][1]] + ) + + def grid_generator(self): + """ yields the grid planes """ + grid_plane_params = { + 'xl': self.GridPlaneParams('x', 0, 'y', 'z'), + 'xr': self.GridPlaneParams('x', 1, 'y', 'z'), + 'yl': self.GridPlaneParams('y', 0, 'x', 'z'), + 'yr': self.GridPlaneParams('y', 1, 'x', 'z'), + 'zb': self.GridPlaneParams('z', 0, 'x', 'y'), + 'zt': self.GridPlaneParams('z', 1, 'x', 'y'), + } + for key, params in grid_plane_params.items(): + plane = params.plane + side = params.side + coord1 = params.coord1 + coord2 = params.coord2 + yield key, GLGridPlane( + parentItem=self, + plane=plane, + offset=self.limits[plane][side], + coords=[self.coords[coord1], self.coords[coord2]], + limits=[self.limits[coord1], self.limits[coord2]], + ) + + def label_generator(self): + """ yields the axis labels """ + axis_labels_params = { + 'xmm': self.AxisParams('x', 'mm', 'y', 'z'), + 'xmp': self.AxisParams('x', 'mp', 'y', 'z'), + 'xpm': self.AxisParams('x', 'pm', 'y', 'z'), + 'xpp': self.AxisParams('x', 'pp', 'y', 'z'), + 'ymm': self.AxisParams('y', 'mm', 'x', 'z'), + 'ypm': self.AxisParams('y', 'pm', 'x', 'z'), + 'ymp': self.AxisParams('y', 'mp', 'x', 'z'), + 'ypp': self.AxisParams('y', 'pp', 'x', 'z'), + 'zrmm': self.AxisParams('z', 'rmm', 'x', 'y'), + 'zrmp': self.AxisParams('z', 'rmp', 'x', 'y'), + 'zrpm': self.AxisParams('z', 'rpm', 'x', 'y'), + 'zrpp': self.AxisParams('z', 'rpp', 'x', 'y'), + 'zlmm': self.AxisParams('z', 'lmm', 'x', 'y'), + 'zlmp': self.AxisParams('z', 'lmp', 'x', 'y'), + 'zlpm': self.AxisParams('z', 'lpm', 'x', 'y'), + 'zlpp': self.AxisParams('z', 'lpp', 'x', 'y'), + + } + + for key, params in axis_labels_params.items(): + axis = params.axis + edge = params.edge + coord1 = params.coord1 + coord2 = params.coord2 + yield key, GLAxis( + parentItem=self, + axis=axis+edge, + ax_limits=self.limits[axis], + coords=self.coords[axis], + limits=[self.limits[coord1], self.limits[coord2]], + ) + + def setData(self, **kwargs): + """ Update the axis labels """ + args = ('coords', 'limits') + for k in kwargs.keys(): + if k not in args: + raise ValueError(f'Invalid keyword argument: {k} (allowed arguments are {args})') + for key, value in kwargs.items(): + setattr(self, key, value) + self.orphan_children() + self.set_bounding_box_corners() + self.grid = dict(self.grid_generator()) + self.axes = dict(self.label_generator()) + self.update() + + def _setView(self, v): + super()._setView(v) + for child in self.childItems(): + child._setView(v) + + def best_camera(self, distance_factor=1.5, method='perspective'): + field_of_view = 60 + if method == 'orthographic': + field_of_view = 1 + distance_factor = 1.4 + center = (self.bounding_box_min + self.bounding_box_max) / 2.0 + new_pos = pg.Vector(*center) + bounding_box_size = self.bounding_box_max - self.bounding_box_min + bounding_box_diagonal = np.linalg.norm(bounding_box_size) + fov_rad = np.radians(field_of_view) + camera_distance = (bounding_box_diagonal / 2) / np.tan(fov_rad / 2) * distance_factor + + return {'pos': new_pos, 'distance': camera_distance} + + def show_grids(self, *grid_items): + """Show chosen grid items""" + for item in grid_items: + self.grid[item].show() + + def show_axes(self, *axis_items): + """Show chosen axis items""" + for item in axis_items: + self.axes[item].show() + + def move_axes(self, move_up=False, move_down=False): + """Move chosen axis items up or down""" + axis_items = ('xmm', 'xmp', 'xpm', 'xpp', 'ymm', 'ymp', 'ypm', 'ypp') + for item in axis_items: + if move_up: + self.axes[item].move_up() + if move_down: + self.axes[item].move_down() + + def paint(self): + """Override paintGL() to add custom code to draw the grid and labels""" + super().paint() + + camera_params = self.view().cameraParams() + azimuth, elevation = camera_params['azimuth'], camera_params['elevation'] + azimuth = np.mod(azimuth, 360.0) + + # hide by default + for grid in self.grid.values(): + grid.hide() + for label in self.axes.values(): + label.hide() + + if 0.0 <= azimuth < 90.0: + self.show_axes('xpp', 'ypp') + self.show_grids('xl', 'yl') + elif 90.0 <= azimuth < 180.0: + self.show_axes('xpm', 'ymp') + self.show_grids('xr', 'yl') + elif 180.0 <= azimuth < 270.0: + self.show_axes('xmm', 'ymm') + self.show_grids('xr', 'yr') + else: + self.show_axes('xmp', 'ypm') + self.show_grids('xl', 'yr') + + if 0.0 <= azimuth < 45.0: + self.show_axes('zlmp') + elif 45.0 <= azimuth < 90.0: + self.show_axes('zrmp') + elif 90.0 <= azimuth < 135.0: + self.show_axes('zlmm') + elif 135.0 <= azimuth < 180.0: + self.show_axes('zrmm') + elif 180.0 <= azimuth < 215.0: + self.show_axes('zlpm') + elif 215.0 <= azimuth < 270.0: + self.show_axes('zrpm') + elif 270.0 <= azimuth < 315.0: + self.show_axes('zlpp') + elif 315.0 <= azimuth < 360.0: + self.show_axes('zrpp') + + + if elevation < 0.0: + self.show_grids('zt') + self.move_axes(move_up=True) + else: + self.show_grids('zb') + self.move_axes(move_down=True) diff --git a/mlpyqtgraph/utils/GLTextItem.py b/mlpyqtgraph/utils/GLTextItem.py new file mode 100644 index 0000000..11c9068 --- /dev/null +++ b/mlpyqtgraph/utils/GLTextItem.py @@ -0,0 +1,81 @@ +""" GLTextItem module with advanced text alignment """ + +import OpenGL.GL as gl # noqa +import numpy as np +import pyqtgraph.functions as fn +from pyqtgraph.Qt import QtCore, QtGui +from pyqtgraph.opengl import items + +__all__ = ['GLTextItem'] + + +class GLTextItem(items.GLTextItem.GLTextItem): + """ GLTextItem extended with advanced text alignment """ + + def __init__(self, parentItem=None, **kwds): + """All keyword arguments are passed to setData()""" + super().__init__(parentItem=parentItem) + self.alignment = QtCore.Qt.AlignLeft | QtCore.Qt.AlignBottom + self.setData(**kwds) + + def setData(self, **kwds): + """ + Update the data displayed by this item. All arguments are optional; + for example it is allowed to update text while leaving colors unchanged, etc. + + ==================== ================================================== + **Arguments:** + ------------------------------------------------------------------------ + pos (3,) array of floats specifying text location. + color QColor or array of ints [R,G,B] or [R,G,B,A]. (Default: Qt.white) + text String to display. + font QFont (Default: QFont('Helvetica', 16)) + alignment QtCore.Qt.AlignmentFlag (Default: QtCore.Qt.AlignLeft | QtCore.Qt.AlignBottom) + ==================== ================================================== + """ + args = ['pos', 'color', 'text', 'font', 'alignment'] + for k in kwds.keys(): + if k not in args: + raise ValueError('Invalid keyword argument: %s (allowed arguments are %s)' % (k, str(args))) + for arg in args: + if arg in kwds: + value = kwds[arg] + if arg == 'pos': + if isinstance(value, np.ndarray): + if value.shape != (3,): + raise ValueError('"pos.shape" must be (3,).') + elif isinstance(value, (tuple, list)): + if len(value) != 3: + raise ValueError('"len(pos)" must be 3.') + elif arg == 'color': + value = fn.mkColor(value) + elif arg == 'font': + if isinstance(value, QtGui.QFont) is False: + raise TypeError('"font" must be QFont.') + setattr(self, arg, value) + self.update() + + def align_text(self, pos): + """ + Aligns the text at the given position according to the given alignment. + """ + font_metrics = QtGui.QFontMetrics(self.font) + rect = font_metrics.tightBoundingRect(self.text) + width = rect.width() + height = rect.height() + dx = dy = 0.0 + if self.alignment & QtCore.Qt.AlignRight: + dx = width + if self.alignment & QtCore.Qt.AlignHCenter: + dx = width / 2.0 + if self.alignment & QtCore.Qt.AlignTop: + dy = height + if self.alignment & QtCore.Qt.AlignVCenter: + dy = height / 2.0 + pos.setX(pos.x() - dx) + pos.setY(pos.y() - dy) + + def __project(self, obj_pos, modelview, projection, viewport): + text_pos = super().__project(obj_pos, modelview, projection, viewport) + self.align_text(text_pos) + return text_pos diff --git a/mlpyqtgraph/utils/ticklabels.py b/mlpyqtgraph/utils/ticklabels.py new file mode 100644 index 0000000..81dd0cf --- /dev/null +++ b/mlpyqtgraph/utils/ticklabels.py @@ -0,0 +1,73 @@ +""" Module to determine nice ticklabels """ + +import math +import numpy as np + + +class NiceTicks: + """ Determine nice ticklabels values """ + fractions = (1, 2, 5, 10) + limit_fractions = ((1.5, 1), (3, 2), (7, 5)) + + def __init__(self, minv, maxv, max_ticks=6): + self.max_ticks = max_ticks + self.tick_spacing = 0 + self.nice_min = 0 + self.nice_max = 0 + self.calculate_tick_params(minv, maxv) + + def calculate_tick_params(self, min_point, max_point): + """ Calculate nice tick parameters """ + lst = self.nice_number(max_point - min_point, False) + tick_spacing = self.nice_number(lst / (self.max_ticks - 1.0), True) + self.tick_spacing = tick_spacing + self.nice_min = tick_spacing*math.floor(min_point / tick_spacing) + self.nice_max = tick_spacing*math.ceil(max_point / tick_spacing) + + def tick_values(self): + """ Return nice tick values """ + return np.arange( + self.nice_min, + self.nice_max+self.tick_spacing, + self.tick_spacing + ) + + def nice_fraction(self, fraction, rround): + """ Return nice fraction """ + if (rround): + return next( + (f for limit, f in self.limit_fractions if fraction < limit), + 10 + ) + return next((f for f in self.fractions if fraction <= f), 10) + + def nice_number(self, value, rround): + """ Return nice number """ + exponent = math.floor(math.log10(value)) + fraction = value / 10**exponent + return self.nice_fraction(fraction, rround) * 10**exponent + + +def nice_ticks(data_min, data_max, num_ticks=6): + """Calculate nice axis tick positions and labels. """ + nice_ticks = NiceTicks(data_min, data_max) + return nice_ticks.tick_values() + + +def coord_limits(coord, limit_ratio=0.05): + """ Define the grid points in the plain defined by axis=offset """ + limit_distance = limit_ratio*abs(coord[0]-coord[-1]) + limits = (coord[0] - limit_distance, coord[-1] + limit_distance) + return limits + + +def coord_generator(num_ticks=6, **input_data): + """Yield nice axis tick positions """ + for label, data in input_data.items(): + yield label, nice_ticks(np.min(data), np.max(data), num_ticks=6) + + +def limit_generator(limit_ratio=0.05, **coord_data): + """Yield nice axis limits """ + for label, data in coord_data.items(): + yield label, coord_limits(data, limit_ratio=0.05) diff --git a/mlpyqtgraph/windows.py b/mlpyqtgraph/windows.py index 7951940..3a3557a 100644 --- a/mlpyqtgraph/windows.py +++ b/mlpyqtgraph/windows.py @@ -27,37 +27,17 @@ class NoFigureLayout(RootException): """ This Exception is raised the figure layout has not been set """ -class GridLayoutWidget(QtWidgets.QWidget): - """ Custom layout widget with GridLayout to mimic pyqtgraph's layout widget """ - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setLayout(QtWidgets.QGridLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - - def getItem(self, row, column): - """ Returns the item at row, col. If empty return None """ - return self.layout().itemAtPosition(row, column) - - def addItem(self, item, row=0, column=0, rowSpan=1, columnSpan=1): - """ Adds an item at row, col with rowSpand and colSpan """ - self.layout().addWidget(item, row, column, rowSpan, columnSpan) - - def removeItem(self, item): - """ Removes an item from the layout """ - self.layout().removeItem(item) - - class FigureWindow(QtCore.QObject): """ Controls a figure window instance """ triggered = QtCore.Signal() axis_factory = None - def __init__(self, index, title='Figure', width=600, height=500, parent=None): + def __init__(self, index, title='Figure', width=600, height=500, layout_type='pg', parent=None): super().__init__(parent=parent) self.index = index - self.layout_type = 'None' + self.layout_type = None self.window = self.setup_window(parent, width, height) - self.change_layout() + self.change_layout(layout_type) self.title = f'Figure {index+1}: {title}' self.window.show() @@ -69,16 +49,17 @@ def setup_window(self, parent, width, height): def change_layout(self, layout_type='pg'): """ - Change the figure's layout type; pg for pyqtgraph's native layout or Qt layout + Change the figure's layout type; 'pg' for pyqtgraph's native layout or 'Qt' + layout. Returns: boolean indicating layout change """ - LayoutWidget = pg.GraphicsLayoutWidget if self.layout_type == layout_type: return False self.layout_type = layout_type - if self.layout_type == 'Qt': - LayoutWidget = GridLayoutWidget + LayoutWidget = pg.GraphicsLayoutWidget + if layout_type == 'Qt': + LayoutWidget = pg.opengl.GLViewWidget self.window.setCentralWidget(LayoutWidget()) return True