Source code for gui.qtcanvas

#
##
##  SPDX-FileCopyrightText: © 2007-2023 Benedict Verhegghe <bverheg@gmail.com>
##  SPDX-License-Identifier: GPL-3.0-or-later
##
##  This file is part of pyFormex 3.4  (Thu Nov 16 18:07:39 CET 2023)
##  pyFormex is a tool for generating, manipulating and transforming 3D
##  geometrical models by sequences of mathematical operations.
##  Home page: https://pyformex.org
##  Project page: https://savannah.nongnu.org/projects/pyformex/
##  Development: https://gitlab.com/bverheg/pyformex
##  Distributed under the GNU General Public License version 3 or later.
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see http://www.gnu.org/licenses/.
##
"""Interactive OpenGL Canvas embedded in a Qt widget.

This module implements user interaction with the OpenGL canvas defined in
module :mod:`canvas`.
`QtCanvas` is a single interactive OpenGL canvas, while `MultiCanvas`
implements a dynamic array of multiple canvases.
"""
import math
import numpy as np

import pyformex as pf
from pyformex import utils
from pyformex.formex import Formex
from pyformex.mesh import Mesh

from pyformex.opengl import canvas
from pyformex.opengl.gl import GL

from pyformex import arraytools as at
from pyformex.gui import QtCore, QtGui, QtOpenGL, QtWidgets
from pyformex.gui import qtutils
from pyformex.gui import qtgl
from pyformex.gui import image
from pyformex.plugins import imagearray


from pyformex.collection import Collection
from pyformex.config import Config
from pyformex.coords import Coords
from pyformex.arraytools import isInt, unitVector, stuur, checkInt
from pyformex.gui.signals import Signal

# Some 2D vector operations
# We could do this with the general functions of coords.py,
# but that would be overkill for this simple 2D vectors

[docs]def dotpr(v, w): """Return the dot product of vectors v and w""" return v[0]*w[0] + v[1]*w[1]
[docs]def length(v): """Return the length of the vector v""" return math.sqrt(dotpr(v, v))
[docs]def projection(v, w): """Return the (signed) length of the projection of vector v on vector w.""" return dotpr(v, w)/length(w)
################# Constants for event handlers ######################### # keys ESC = QtCore.Qt.Key_Escape RETURN = QtCore.Qt.Key_Return # Normal Enter ENTER = QtCore.Qt.Key_Enter # Num Keypad Enter # mouse actions PRESS = 0 MOVE = 1 RELEASE = 2 # mouse buttons LEFT = QtCore.Qt.LeftButton MIDDLE = QtCore.Qt.MidButton RIGHT = QtCore.Qt.RightButton # modifiers NONE = QtCore.Qt.NoModifier SHIFT = QtCore.Qt.ShiftModifier CTRL = QtCore.Qt.ControlModifier ALT = QtCore.Qt.AltModifier META = QtCore.Qt.MetaModifier ALLMODS = SHIFT | CTRL | ALT | META _modifier = { 'NONE': NONE, 'SHIFT': SHIFT, 'CTRL': CTRL, 'ALT': ALT, 'META': META, } # mouse modifiers used during picking actions _PICK_MOVE = [_modifier[i] for i in pf.cfg['gui/mouse_mod_move']] _PICK_SET = _modifier[pf.cfg['gui/mouse_mod_set']] _PICK_ADD = _modifier[pf.cfg['gui/mouse_mod_add']] _PICK_REMOVE = _modifier[pf.cfg['gui/mouse_mod_remove']] ################# Canvas Mouse Event Handler ######################### def custom_cursor(base): cbfile = pf.cfg['icondir'] / base + '-cb.xpm' cmfile = pf.cfg['icondir'] / base + '-cm.xpm' cb = QtGui.QPixmap(cbfile) cm = QtGui.QPixmap(cmfile) return QtGui.QCursor(cb, cm) # class CursorShapeHandler(): # """A class for handling the mouse cursor shape on the Canvas. # """ # cursor_shape = {'default': QtCore.Qt.ArrowCursor, # 'pick': QtCore.Qt.CrossCursor, # 'busy': QtCore.Qt.BusyCursor, # } # custom_cursors = ['mouse-pick'] # def __init__(self, widget): # """Create a CursorHandler for the specified widget.""" # self.widget = widget # def setCursorShape(self, shape): # """Set the cursor shape to shape""" # if shape in custom_cursors: # cursor = custom_cursor(shape), # else: # if shape not in QtCanvas.cursor_shape: # shape = 'default' # cursor = QtCanvas.cursor_shape[shape] # self.setCursor(cursor) # def setCursorShapeFromFunc(self, func): # """Set the cursor shape to shape""" # if func in [self.mouse_rectangle]: # shape = 'mouse-pick' # else: # shape = 'default' # self.setCursorShape(shape)
[docs]class MouseHandler(): """A class for handling the mouse events on the Canvas. mousefunc keeps track of the installed mouse functions. For each combination of mouse button and modifier key we keep a list of functions. Installing a function adds it at the start of the list. The first of the list is the active function. Reset pops the first off the list, making the next active. """ buttons = [None, LEFT, MIDDLE, RIGHT] # None is relevant for mouse tracking modifiers = [NONE, SHIFT, CTRL, ALT, META] cursor_shape = {'default': QtCore.Qt.ArrowCursor, 'cross': QtCore.Qt.CrossCursor, 'draw': QtCore.Qt.CrossCursor, 'busy': QtCore.Qt.BusyCursor, } custom_cursor_shape = {'pick': 'mouse-pick'} def __init__(self, canvas): self.canvas = canvas self.mousefnc = {} for button in MouseHandler.buttons: self.mousefnc[button] = {} for mod in MouseHandler.modifiers: self.mousefnc[button][int(mod)] = [] def set(self, button, mod, func): self.mousefnc[button][int(mod)].append(func) def reset(self, button, mod): try: self.mousefnc[button][int(mod)].pop() except IndexError: pass
[docs] def get(self, button, mod): """Return the mouse function bound to button and mod""" try: return self.mousefnc[button][int(mod)][-1] except IndexError: return None
[docs] def setCursorShape(self, shape): """Set the cursor shape to shape""" if shape in MouseHandler.custom_cursor_shape: cursor = custom_cursor(MouseHandler.custom_cursor_shape[shape]) else: if shape not in MouseHandler.cursor_shape: shape = 'default' cursor = MouseHandler.cursor_shape[shape] self.canvas.setCursor(cursor)
################# Single Interactive OpenGL Canvas ###############
[docs]class QtCanvas(QtOpenGL.QGLWidget, canvas.Canvas): """A canvas for OpenGL rendering. This class provides interactive functionality for the OpenGL canvas provided by the :class:`canvas.Canvas` class. Interactivity is highly dependent on Qt. Putting the interactive functions in a separate class makes it esier to use the Canvas class in non-interactive situations or combining it with other GUI toolsets. The QtCanvas constructor may have positional and keyword arguments. The positional arguments are passed to the QtOpenGL.QGLWidget constructor, while the keyword arguments are passed to the canvas.Canvas constructor. """ _exclude_members_ = ['Communicate'] selection_filters = ['none', 'single', 'closest', 'conn0', 'conn1', 'conn2'] # private signal class class Communicate(QtCore.QObject): RECTANGLE = Signal() CANCEL = Signal() DONE = Signal() def __init__(self, *args, **kargs): """Initialize an empty canvas.""" QtOpenGL.QGLWidget.__init__(self, *args) if pf.DEBUG.OPENGL in pf.options.debuglevel: fmt = qtgl.OpenGLFormat(self.format()) pf.debug(f"QtCanvas.__init__:\n{fmt}", pf.DEBUG.OPENGL) # TODO: In case of multisample, report the number of samples here # Define our private signals self.signals = self.Communicate() self.setMinimumSize(32, 32) self.setSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.MinimumExpanding) self.setFocusPolicy(QtCore.Qt.StrongFocus) canvas.Canvas.__init__(self, **kargs) self.mousehandler = MouseHandler(self) # Initial mouse funcs are dynamic handling # Also some modifier keys are bound to mouse movement operations # These can be used during picking operations for mod in set(_PICK_MOVE): self.mousehandler.set(LEFT, mod, self.dynarot) self.mousehandler.set(MIDDLE, mod, self.dynapan) self.mousehandler.set(RIGHT, mod, self.dynazoom) self.mousehandler.setCursorShape('default') self.button = None self.mod = NONE self.dynamouse = True # dynamic mouse action works on mouse move self.dynamic = None # what action on mouse move self.pick_modes = ['actor', 'element', 'face', 'edge', 'point'] self.pick_tools = ['pix', 'any', 'all'] self.pick_mode = None self.pick_tool = pf.cfg['draw/picktool'] self.selection = Collection() self.trackfunc = None self.picked = None self.pickable = None self.drawmode = None self.drawing_mode = None self.drawing = None # Drawing options self.resetOptions() # def setCursorShape(self, shape): # """Set the cursor shape to shape""" # if shape in MouseHandler.custom_cursor_shape: # cursor = custom_cursor(MouseHandler.custom_cursor_shape[shape]) # else: # if shape not in MouseHandler.cursor_shape: # shape = 'default' # cursor = MouseHandler.cursor_shape[shape] # self.canvas.setCursor(cursor) # def setCursorShapeFromFunc(self, func): # """Set the cursor shape according to the specified function""" # if func in [self.mouse_rectangle]: # shape = 'pick' if self.canvas.pick_mode == 'point' else 'cross' # elif func == self.mouse_draw: # shape = 'draw' # else: # shape = 'default' # self.mousehandler.setCursorShape(shape)
[docs] def getSize(self): """Return the size of this canvas""" return qtutils.Size(self)
[docs] def saneSize(self, width=-1, height=-1): """Return a cleverly resized canvas size. Computes a new size for the canvas, while trying to keep its current aspect ratio. Specified positive values are returned unchanged. Parameters ---------- width: int Requested width of the canvas. If <=0, it is automatically computed from height and canvas aspect ratio, or set equal to canvas width. height: int Requested height of the canvas. If <=0, it is automatically computed from width and canvas aspect ratio, or set equal to canvas height. Returns ------- width: int Adjusted canvas width. height: int Adjusted canvas height. """ if width <= 0 or height <= 0: wc, hc = self.getSize() if height > 0: width = round(float(height)/hc*wc) elif width > 0: height = round(float(width)/wc*hc) else: width, height = wc, hc return width, height
# TODO: negative sizes should probably resize all viewports # OR we need to implement frames
[docs] def changeSize(self, width, height): """Resize the canvas to (width x height). If a negative value is given for either width or height, the corresponding size is set equal to the maximum visible size (the size of the central widget of the main window). Note that this may not have the expected result when multiple viewports are used. """ if width < 0 or height < 0: w, h = pf.GUI.maxCanvasSize() if width < 0: width = w if height < 0: height = h self.resize(width, height)
[docs] def image(self, *, resize=None, picking=None, remove_alpha=True): """Return the current OpenGL rendering in an image format. Parameters ---------- resize: tuple of int, optional A tuple (width, height) with the requested image size. If either of these values is <= 0, it will be set from the other and the canvas aspect ratio. If not provided or both values are <= 0, the current canvas size will be used. remove_alpha: bool If True (default), the alpha channel is removed from the image. Returns ------- qim: QImage The current OpenGL rendering as a QImage of the specified size. Notes ----- The returned image can be written directly to an image file with ``qim.save(filename)``. See Also -------- rgb: returns the canvas rendering as a numpy ndarray """ self.makeCurrent() w, h = self.getSize() if resize: wc, hc = w, h w, h = self.saneSize(*resize) vcanvas = QtOpenGL.QGLFramebufferObject( w, h, QtOpenGL.QGLFramebufferObject.Depth) # With new FrameBufferObject # vcanvas = QtGui.QOpenGLFramebufferObject( # w, h, QtGui.QOpenGLFramebufferObject.Depth) # print("IMAGE", vcanvas.attachment()) vcanvas.bind() if resize: self.resize(w, h) if picking: self.renderpick(picking) else: self.display() self.glFinish() qim = vcanvas.toImage() vcanvas.release() if resize: self.resize(wc, hc) self.glFinish() del vcanvas if picking: # restore non-picking mode self.picking = False self.display() self.update() imagearray.removeAlpha(qim).save('pick_original.png') if remove_alpha: qim = imagearray.removeAlpha(qim) return qim
[docs] def rgb(self, resize=None, remove_alpha=True, picking=False): """Return the current OpenGL rendering in an array format. Parameters ---------- resize: tuple of int, optional A tuple (width, height) with the requested image size. If either of these values is <= 0, it will be set from the other and the canvas aspect ratio. If not provided or both values are <= 0, the current canvas size will be used. remove_alpha: bool If True (default), the alpha channel is removed from the image. picking: bool This argument is for internal use only. Returns ------- ar: array The current OpenGL rendering as a numpy array of type uint. Its shape is (w,h,3) if remove_alpha is True (default) or (w,h,4) if remove_alpha is False. See Also -------- image: return the current rendering as an image """ qim = self.image(resize=resize, remove_alpha=False, picking=picking) ar, cm = imagearray.qimage2numpy(qim) if remove_alpha: ar = ar[..., :3] return ar
[docs] def split_pickids(self, ids, obj_type='element'): """Convert picked pixel ids to element Collection""" K = Collection(obj_type=obj_type) key = 0 for start, end in zip(self.pick_nitems[:-1], self.pick_nitems[1:]): mine = (ids >= start) * (ids < end) if obj_type == 'actor' and ids[mine].any(): K.add([key], -1) elif obj_type == 'element': K.add(ids[mine] - start, key) else: # obj_type = 'point' oids = ids[mine] - start actor = self.actors[key] if isinstance(actor.object, Formex): oids = actor._translate_mesh_points_formex(oids) K.add(oids, key) key += 1 return K
[docs] def insideRect(self, rect=None, obj_type='element'): """Find collection of elements inside a rectangle""" if rect is None: rect = self.getRectangle() x0, y0, x1, y1 = rect h = self.height() qim = self.image(picking=obj_type) if pf.debugon(pf.DEBUG.PICK): qimmy = qim.copy(x0+1,h-y1+1,x1-x0-1,y1-y0-1) # qt has y downwards savefile = pf.preffile.parent / 'pick_debug.png' imagearray.removeAlpha(qimmy).save(savefile) crop = imagearray.qimage2numpy(qim, indexed=False) if (x0,y0) == (x1,y1): # No movement: pick pixel under mouse crop = crop[y0, x0] else: x1, y1 = max(x1, x0+2), max(y1, y0+2) crop = crop[y0+1:y1, x0+1:x1] crop = crop.reshape(-1,4) print uniq = at.uniqueRows(crop) crop = crop[uniq] ids = crop.view(np.uint32).reshape(-1) return self.split_pickids(ids, obj_type=obj_type)
[docs] def outline(self, size=(0, 0), profile='luminance', level=0.5, bgcolor=None, nproc=None): """Return the outline of the current rendering Parameters ---------- size: tuple A tuple of ints (w,h) specifying the size of the image to be used in outline detection. A non-positive value will be set automatically from the current canvas size or aspect ratio. profile: callable The function to be used to translate pixel colors into a single value. The default is to use the luminance of the pixel color. level: float The isolevel at which to construct the outline. bgcolor: color_like A color that is to be interpreted as background color and will get a pixel value -0.5. This is currently experimental. nproc: int The number of processors to be used in the image processing. Default is to use as many as available. Returns ------- Formex: The outline as a Formex of plexitude 2. """ from pyformex.plugins.isosurface import isoline from pyformex.formex import Formex from pyformex.colors import luminance, RGBcolor self.camera.lock() w, h = self.saneSize(*size) data = self.rgb((w,h)) shape = data.shape[:2] if bgcolor: bgcolor = RGBcolor(bgcolor) print("bgcolor = %s" % (bgcolor,)) bg = (data==bgcolor).all(axis=-1) print(bg) data = luminance(data.reshape(-1, 3)).reshape(shape) + 0.5 data[bg] = -0.5 else: data = luminance(data.reshape(-1, 3)).reshape(shape) rng = data.max() - data.min() bbox = self.bbox ctr = self.camera.project((bbox[0]+bbox[1])*.05) axis = unitVector(self.camera.eye-self.camera.focus) # # NOTE: the + [0.5,0.5] should be checked and then be # moved inside the isoline function! # seg = isoline(data, data.min()+level*rng, nproc=nproc) + [0.5, 0.5] if size is not None: wc, hc = self.getSize() sx = float(wc)/w sy = float(hc)/h #print("Post scaling %s,%s" % (sx,sy)) seg[..., 0] *= sx seg[..., 1] *= sy X = Coords(seg).trl([0., 0., ctr[0][2]]) shape = X.shape X = self.camera.unproject(X.reshape(-1, 3)).reshape(shape) self.camera.unlock() F = Formex(X) F.attrib(axis=axis) return F
####################### MOUSE RECTANGLE ############################ def clip_coords(self, x, y): w, h = self.width(), self.height() x = 0 if x < 0 else w if x > w else x y = 0 if y < 0 else h if y > h else y return x, y
[docs] def draw_state_line(self, x, y): """Store the pos and draw a rectangle to it.""" self.state = self.clip_coords(x, y) canvas.drawLine(self.statex, self.statey, *self.state)
[docs] def draw_state_rect(self, x, y): """Store the pos and draw a line to it.""" self.state = self.clip_coords(x, y) canvas.drawRect(self.statex, self.statey, *self.state)
[docs] def wait_interaction(self): """Wait for the user to finish some interaction.""" timer = QtCore.QThread self.interaction_busy = True while self.interaction_busy: # This allows us to push mouse rectangle picking events if self.events: self.emit_events(self.events.pop(0)) timer.msleep(20) pf.app.processEvents()
def start_rectangle(self, func=None): self.rectangle = None self.rectangle_func = func self.mousehandler.set(LEFT, NONE, self.mouse_rectangle) self.mousehandler.setCursorShape('cross') if func is None: func = self.finish_rectangle self.signals.RECTANGLE.connect(func) def finish_rectangle(self): self.mousehandler.reset(LEFT, NONE) self.mousehandler.setCursorShape('default') self.update()
[docs] def mouse_rectangle(self, x, y, action): """Draw a rectangle during mouse move. On PRESS, record the mouse position. On MOVE, show a rectangle. On RELEASE, store the picked rectangle and possibly execute a function """ if action == PRESS: self.makeCurrent() self.update() if self.trackfunc: self.camera.setTracking(True) x, y, z = self.camera.focus self.zplane = self.project(x, y, z, True)[2] self.trackfunc(x, y, self.zplane) self.begin_2D_drawing() GL.glEnable(GL.GL_COLOR_LOGIC_OP) GL.glLogicOp(GL.GL_INVERT) # An alternative is GL_XOR # self.draw_state_rect(x, y) # Draw rectangle self.swapBuffers() elif action == MOVE: if self.trackfunc: self.trackfunc(x, y, self.zplane) self.draw_state_rect(*self.state) # Remove old rectangle self.draw_state_rect(x, y) # Draw new rectangle self.swapBuffers() elif action == RELEASE: self.draw_state_rect(*self.state) # Remove old rectangle GL.glDisable(GL.GL_COLOR_LOGIC_OP) self.swapBuffers() self.end_2D_drawing() x0 = max(min(self.statex, x), 0) y0 = max(min(self.statey, y), 0) x1 = min(max(self.statex, x), self.width()) y1 = min(max(self.statey, y), self.height()) self.rectangle = x0, y0, x1, y1 self.interaction_busy = False
[docs] def mouse_line(self, x, y, action): """Draw a line during mouse move. On PRESS, record the mouse position. On MOVE, create a rectangular zoom window. On RELEASE, store the picked rectangle and possibly execute a function (self.statex, self.statey) is the start point self.state is the current end point """ if action == PRESS: self.makeCurrent() self.update() if self.trackfunc: self.camera.setTracking(True) x, y, z = self.camera.focus self.zplane = self.project(x, y, z, True)[2] self.trackfunc(x, y, self.zplane) self.begin_2D_drawing() GL.glEnable(GL.GL_COLOR_LOGIC_OP) GL.glLogicOp(GL.GL_INVERT) # An alternative is GL_XOR # self.draw_state_line(x, y) # Draw line self.swapBuffers() elif action == MOVE: if self.trackfunc: self.trackfunc(x, y, self.zplane) self.draw_state_line(*self.state) # Remove oldline self.draw_state_line(x, y) # Draw new line self.swapBuffers() elif action == RELEASE: self.draw_state_line(*self.state) # Remove line GL.glDisable(GL.GL_COLOR_LOGIC_OP) self.swapBuffers() self.end_2D_drawing() self.drawn = self.unproject(x, y, self.zplane) self.interaction_busy = False
def mouse_draw(self, x, y, action): """Process mouse events during interactive drawing. On PRESS, do nothing. On MOVE, do nothing. On RELEASE, compute the unprojected point """ if action == PRESS: self.makeCurrent() self.update() if self.trackfunc: print("ENABLE TRACKING") self.camera.setTracking(True) elif action == MOVE: if pf.app.hasPendingEvents(): return if self.trackfunc: self.trackfunc(x, y, self.zplane) if self.previewfunc: self.swapBuffers() self.drawn = self.unproject(x, y, self.zplane) self.previewfunc(self) self.swapBuffers() elif action == RELEASE: self.drawn = self.unproject(x, y, self.zplane) self.interaction_busy = False
[docs] def getRectangle(self, yup=True): """Let the user pick a rectangle. Returns: x0, y0, x1, y1 where x0<x1, y0<y1 If yup is False, y values are downwards """ self.start_rectangle() self.wait_interaction() self.finish_rectangle() if yup: return self.rectangle else: h = self.height() x0, y0, x1, y1 = self.rectangle return x0, h-y1, x1, h-y0
def zoom_rectangle(self): self.zoomRectangle(*self.getRectangle()) ####################### INTERACTIVE PICKING ############################
[docs] def start_selection(self, mode, tool, filter, pickable=None): """Start an interactive picking mode. If selection mode was already started, mode is disregarded and this can be used to change the tool or filter. """ if pf.debugon(pf.DEBUG.PICK): print(f"PICK: Start selection {mode=}, {tool=}, {filter=}") if self.pick_mode is None: self.pick_mode = mode self.mousehandler.set(LEFT, NONE, self.mouse_rectangle) self.mousehandler.set(LEFT, SHIFT, self.mouse_rectangle) self.mousehandler.set(LEFT, CTRL, self.mouse_rectangle) self.mousehandler.set(RIGHT, NONE, self.emit_done) self.mousehandler.set(RIGHT, SHIFT, self.emit_cancel) self.mousehandler.setCursorShape( 'pick' if self.pick_mode == 'point' else 'cross') self.signals.DONE.connect(self.accept_selection) self.signals.CANCEL.connect(self.cancel_selection) self.pickable = pickable self.selection_front = None self.pick_tool = tool if filter == 'none': filter = None self.selection_filter = filter if filter is None: self.selection_front = None self.selection.clear() self.selection.obj_type = self.pick_mode if pf.debugon(pf.DEBUG.PICK): print(f"PICK started: {self.pick_mode=}, {self.selection}") self.removeHighlight()
[docs] def finish_selection(self): """End an interactive picking mode.""" if pf.debugon(pf.DEBUG.PICK): print("Finish selection") self.mousehandler.reset(LEFT, NONE) self.mousehandler.reset(LEFT, SHIFT) self.mousehandler.reset(LEFT, CTRL) self.mousehandler.reset(RIGHT, NONE) self.mousehandler.reset(RIGHT, SHIFT) self.mousehandler.setCursorShape('default') self.signals.DONE.disconnect(self.accept_selection) self.signals.CANCEL.disconnect(self.cancel_selection) self.pick_mode = None self.pickable = None
[docs] def accept_selection(self, clear=False): """Accept or cancel an interactive picking mode. If clear == True, the current selection is cleared. """ if pf.debugon(pf.DEBUG.PICK): print("Accept selection") self.selection_accepted = True if clear: self.selection.clear() self.selection_accepted = False self.selection_canceled = True self.interaction_busy = False
[docs] def cancel_selection(self): """Cancel an interactive picking mode and clear the selection.""" self.accept_selection(clear=True)
def emit_events(self, events): #pf.logger.debug("sending events %s" % events) for event in events: pf.app.sendEvent(self, event)
[docs] def pick_pixels(self): """Set the list of actor parts inside the pick_window. This implements the 'pix' picking tool. The picked object numbers are stored in self.picked. """ if pf.debugon(pf.DEBUG.PICK): print(f"PICK_PIXELS {self.pick_mode}") # Allow a different pickable list than the pickable actors. # This is used in the draw2d plugin. if self.pickable is None: pickable = [a for a in self.actors if a.pickable] else: pickable = self.pickable self.picked = self.insideRect(self.rectangle, self.pick_mode) if pf.debugon(pf.DEBUG.PICK): print(f"self.picked={self.picked}")
[docs] def pick_parts(self): """Set the list of actor parts inside the pick_window. This implements the 'any' and 'all' picking tool. The picked object numbers are stored in self.picked. """ if pf.debugon(pf.DEBUG.PICK): print(f"PICK_PARTS {self.pick_mode=} " f"{self.pick_tool=} {store_closest=}") # Allow a different pickable list than the pickable actors. # This is used in the draw2d plugin. if self.pickable is None: pickable = [a for a in self.actors if a.pickable] else: pickable = self.pickable self.picked = Collection(self.pick_mode) x0, y0, x1, y1 = self.rectangle x, y = 0.5 * (x0 + x1), 0.5 * (y0 + y1) w, h = x1 - x0, y1 - y0 if w <= 1 or h <= 1: w, h = pf.cfg['draw/picksize'] vp = GL.glGetIntegerv(GL.GL_VIEWPORT) self.pick_window = (x, y, w, h, vp) if pf.debugon(pf.DEBUG.PICK): print(f"{self.pick_window=}") # Make sure we always return Actor index from self.actors for i, a in enumerate(self.actors): if a in pickable: picked = a.inside( self.camera, rect=self.pick_window[:4], mode=self.pick_mode, sel=self.pick_tool, return_depth=False) if self.pick_mode == 'actor': if picked: self.picked.add([i], key=-1) else: self.picked.add(picked, key=i)
[docs] def filter_closest(self, picked): """Narrow a Collection to its single item closest to the camera plane""" if not picked: return picked imin = -1 jmin = None dmin = None if picked.obj_type == 'actor': for i in picked[-1]: o = self.actors[i].object # we use normal towards objects to have positive distances d = o.points().distanceFromPlane( self.camera.eye, -self.camera.axis) d = d.min() if imin < 0 or d < dmin: imin, dmin = i, d picked.clear() picked.add([imin], key=-1) picked.depth = dmin elif picked.obj_type == 'point': for i in picked: v = picked[i] o = self.actors[i].object d = o.points()[v].distanceFromPlane( self.camera.eye, -self.camera.axis) j = d.argmin() if imin < 0 or d[j] < dmin: imin, jmin, dmin = i, v[j], d[j] picked.clear() picked.add([jmin], key=imin) picked.depth = dmin elif picked.obj_type == 'element': for i in picked: v = picked[i] o = self.actors[i].object if isinstance(o, Formex): X = o.coords[v] elif isinstance(o, Mesh): X = o.coords[o.elems[v]] d = X.points().distanceFromPlane( self.camera.eye, -self.camera.axis) j = d.argmin() k = j // X.shape[1] if imin < 0 or d[j] < dmin: imin, jmin, dmin = i, v[k], d[j] picked.clear() picked.add([jmin], key=imin) picked.depth = dmin
[docs] def filter_connected(self, picked, level=1): """Narrow a Collection to the items connected to self.selection""" if not picked: return if not self.selection: # set to closest picked item self.selection = picked.copy() self.filter_closest(self.selection) print(f"INITIAL SELECTION {self.selection}") imin = -1 jmin = None dmin = None if picked.obj_type == 'element': for i in self.selection: if i in picked: o = self.actors[i].object if not isinstance(o, Mesh): del picked[i] else: start = self.selection[i] new = self.picked[i] test = np.union1d(start, new) ok = o.connectedElements(start, test, level) self.picked[i] = np.intersect1d(ok, new)
[docs] def modify_selection(self): """Modify the current selection. This method is intended for use in the `func` of the :meth:`pick` method, to update the selection after each atomic pick. It modifies the selection depending on the used filters and on the modifier key pressed when doing the pick. Default is: - None: add to the selection - SHIFT: set as the selection (forgetting previous picks) - CTRL: remove from the selection Without filter, all the items in the last pick are involved. With a filter only a subset may be involved. """ if self.mod == _PICK_SET: self.selection.set(self.picked) elif self.mod == _PICK_ADD: self.selection.add(self.picked) elif self.mod == _PICK_REMOVE: self.selection.remove(self.picked) if self.selection_filter == 'single': self.filter_closest(self.selection)
[docs] def modify_and_highlight(self): """Modify selection and highlight updated selection. This method is the default `func` used in the pick method after each atomic pick. It modifies the selection according to the modifiers and filters, and highlights the resulting selection. """ self.modify_selection() self.highlightSelection(self.selection)
[docs] def pick(self, mode, tool='pix', oneshot=False, func=None, filter=None, pickable=None, _rect=None, minobj=0): """Interactively pick objects from the canvas. Parameters ---------- mode: str Defines what to pick: one of ``actor``, ``element``, ``point``. oneshot: bool If True, the function returns as soon as the user ends an atomic picking operation (left mouse press and release). If False (default) the user can modify his selection until he explicitely accepts (right mouse button press or ENTER) or cancels (ESC) the pick operation. func: callable If provided, this function is called after each atomic pick operation (from mouse button press to mouse button release). The canvas self is passed as an argument. The last atomic pick is then available as `self.picked` and the previously collected selection (if collection is done) is in self.selection. This is commonly used to highlight the picked items, collect picked items, report picked items, compute and display features of picked items. If not provided, the default function :meth:`modify_and_highlight` is used. See there for details. filter: str Defines a filter to retain only some of the picked items in the selection. If not provided, all the picked items are retained. Available filters: - single: keeps only a single item - closest: keeps only the item closest to the user. - conn?: keeps only the items connected to the already selected items or to the closest picked item if nothing has been selected yet. The ? can be one of 0, 1 or 2 to define the level of the connectors (point, edge, face). The default is 1 (edge). The conn? filters only work when picking mode is 'element' and for objects of type Mesh. _rect: tuple A tuple (x0, y0, x1, y1) speciying the rectangular part on the canvas that will be picked. Allows simulated picking. Returns ------- Collection: A (possibly empty) Collection with the picked items. After return, the value of the selection_accepted attribute can be tested to find how the picking operation was exited: - True: the selection was accepted (right mouse click, ENTER key, or OK button), - False: the selection was canceled (ESC key, or Cancel button). In the latter case, the returned Collection is always empty. It is also possible to test on the length of the selection. """ self.setFocus() self.selection_canceled = False self.start_selection(mode, tool, filter, pickable) if not callable(func): func = QtCanvas.modify_and_highlight self.events = [] if _rect: #create events for programmed pick self.events.extend(self.mouse_rect_pick_events(_rect)) try: while not self.selection_canceled: self.wait_interaction() # wait for user to pick a rectangle if not self.selection_canceled: # if pf.debugon(pf.DEBUG.PICK): # print(f"{self.pick_tool=}, {self.rectangle=}") if self.pick_tool == 'pix': self.pick_pixels() # pick by pixels else: self.pick_parts() # pick by points if self.selection_filter in ['single', 'closest']: self.filter_closest(self.picked) elif str(self.selection_filter)[:4] == 'conn': try: connlevel = int(self.selection_filter[5]) except: connlevel = 1 self.filter_connected(self.picked, connlevel) func(self) self.update() if (oneshot or minobj > 0 and self.selection.total() >= minobj): self.accept_selection() finally: self.finish_selection() return self.selection
[docs] def mouse_rect_pick_events(self, rect=None): """Create the events for a mouse rectangle pick. Parameters ---------- rect: tuple of ints, optional A tuple (x0,y0,x1,y1) specifying the top left corner and the bottom right corner of the rectangular are to be picked. Values are in pixels relative to the canvas widget. If not provided, the whole canvas area will be picked. Returns ------- list A nested list of events. The list contains two sublists. The first holds the events to make the rectangle pick: - Press the left button mouse at (x0,y0). - Move the mouse while holding the left button pressed to (x1,y1). - Release the left mouse button at (x1,y1). The second sublist holds the events to accept the picked area: - Press the right mouse button at (x1,y1). - Release the right mouse button at (x1,y1). """ if rect is None: x0, y0 = 0, 0 x1, y1 = self.getSize() else: x0, y0, x1, y1 = rect event1 = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonPress, QtCore.QPoint(x0, y0), QtCore.Qt.LeftButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier) event2 = QtGui.QMouseEvent(QtCore.QEvent.MouseMove, QtCore.QPoint(x1, y1), QtCore.Qt.NoButton, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier) event3 = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonRelease, QtCore.QPoint(x1, y1), QtCore.Qt.LeftButton, QtCore.Qt.NoButton, QtCore.Qt.NoModifier) event4 = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonPress, QtCore.QPoint(x1, y1), QtCore.Qt.RightButton, QtCore.Qt.RightButton, QtCore.Qt.NoModifier) event5 = QtGui.QMouseEvent(QtCore.QEvent.MouseButtonRelease, QtCore.QPoint(x1, y1), QtCore.Qt.RightButton, QtCore.Qt.NoButton, QtCore.Qt.NoModifier) return [[event1, event2, event3], [event4, event5]]
#################### Interactive drawing #################################### def add_point(self): from pyformex.gui import draw as gs self.drawing = Coords.concatenate([self.drawing, self.drawn]) self.removeHighlight() gs.draw(self.drawing, highlight=True, color=0, marksize=10, ontop=True)
[docs] def idraw(self, mode='point', npoints=-1, zplane=0., func=None, coords=None, preview=False, mouseline=False): """Interactively draw on the canvas. This function allows the user to interactively create points in 2.5D space and collects the subsequent points in a Coords object. The interpretation of these points is left to the caller. The drawing operation is finished when the number of requested points has been reached, or when the user clicks the right mouse button or hits 'ENTER' or presses the ESC-button. Parameters ---------- mode: str One of the drawing modes, specifying the kind of objects you want to draw. This is passed to the specified `func`. npoints: int Specifies how many points can be created before returning. If < 0, the continuous drawing mode has to be ended explicitely with an accept or cancel. zplane: float The depth of the z-plane on which the 2D drawing is done. func: callable A function that is called after each atomic drawing operation. It is typically used to accumulate the drawn points in a single set of points and draw a preview of the drawing. If not provided, the default will just do that. The function is passed the canvas as a parameter, from which the following data are available: - canvas.drawn: the newly drawn point, - canvas.drawing: the accumulated set of points - canvas.drawmode: the current drawing mode coords: Coords An initial set of coordinates to which the newly created points should be added. THis can be used to continue a previous idraw operation. If provided, `npoints` also counts these initial points. preview: bool If True, the func will also be called during mouse movement with a depressed button, allowing to preview the result before a point is actually created. Returns ------- Coords (npts, 3) The Coordinates of the created points. On return canvas.draw_accepted will be True if the function returned because the number of points was reached or the result was accepted with a right mouse click or ENTER key; it will be False if the ESC button was hit. """ self.setFocus() self.draw_canceled = False self.start_draw(mode, zplane, coords, mouseline) if not callable(func): func = QtCanvas.add_point self.previewfunc = func if preview else None self.events = [] try: while not self.draw_canceled: self.wait_interaction() if not self.draw_canceled: func(self) self.update() if npoints > 0 and len(self.drawing) >= npoints: self.accept_draw() finally: self.finish_draw() return self.drawing
[docs] def start_draw(self, mode, zplane, coords, mouseline): """Start an interactive drawing mode.""" #self.perspective(False) self.camera.lock() if mouseline: self.mousehandler.set(LEFT, NONE, self.mouse_line) self.mousehandler.set(None, NONE, self.mouse_line) self.mousehandler.setCursorShape('default') else: self.mousehandler.set(LEFT, NONE, self.mouse_draw) self.mousehandler.setCursorShape('draw') self.mousehandler.set(RIGHT, NONE, self.emit_done) self.mousehandler.set(RIGHT, SHIFT, self.emit_cancel) self.signals.DONE.connect(self.accept_draw) self.signals.CANCEL.connect(self.cancel_draw) self.drawmode = mode self.zplane = float(zplane) #print(f"START_DRAW {zplane=}") self.drawing = Coords(coords)
[docs] def finish_draw(self): """End an interactive drawing mode.""" self.mousehandler.reset(None, NONE) self.mousehandler.reset(LEFT, NONE) self.mousehandler.reset(RIGHT, NONE) self.mousehandler.reset(RIGHT, SHIFT) self.mousehandler.setCursorShape('default') self.signals.DONE.disconnect(self.accept_draw) self.signals.CANCEL.disconnect(self.cancel_draw) self.drawmode = None # self.perspective(original_perspective) self.camera.unlock() # should unlock only if it wasn't locked before
[docs] def accept_draw(self, clear=False): """Cancel an interactive drawing mode. If clear == True, the current drawing is cleared. """ self.draw_accepted = True if clear: self.drawing = Coords() self.draw_accepted = False self.draw_canceled = True self.interaction_busy = False
[docs] def cancel_draw(self): """Cancel an interactive drawing mode and clear the drawing.""" self.accept_draw(clear=True)
[docs] def mouse_draw(self, x, y, action): """Process mouse events during interactive drawing. On PRESS, do nothing. On MOVE, do nothing. On RELEASE, compute the unprojected point """ if action == PRESS: self.makeCurrent() self.update() if self.trackfunc: print("ENABLE TRACKING") self.camera.setTracking(True) elif action == MOVE: if pf.app.hasPendingEvents(): return if self.trackfunc: self.trackfunc(x, y, self.zplane) if self.previewfunc: self.swapBuffers() self.drawn = self.unproject(x, y, self.zplane) self.previewfunc(self) self.swapBuffers() elif action == RELEASE: self.drawn = self.unproject(x, y, self.zplane) self.interaction_busy = False
########################################################################## # line drawing mode #
[docs] def drawLinesInter(self, mode='line', oneshot=False, func=None): """Interactively draw lines on the canvas. - oneshot: if True, the function returns as soon as the user ends a drawing operation. The default is to let the user draw multiple lines and only to return after an explicit cancel (ESC or right mouse button). - func: if specified, this function will be called after each atomic drawing operation. The current drawing is passed as an argument. This can e.g. be used to show the drawing. When the drawing operation is finished, the drawing is returned. The return value is a (n,2,2) shaped array. """ self.setFocus() self.drawing_canceled = False self.start_drawing(mode) while not self.drawing_canceled: self.wait_drawing() if not self.drawing_canceled: if self.edit_mode: # an edit mode from the edit combo was clicked if self.edit_mode == 'undo' and self.drawing.size != 0: self.drawing = delete(self.drawing, -1, 0) elif self.edit_mode == 'clear': self.drawing = empty((0, 2, 2), dtype=int) elif self.edit_mode == 'close' and self.drawing.size != 0: line = asarray([self.drawing[-1, -1], self.drawing[0, 0]]) self.drawing = append(self.drawing, line.reshape(-1, 2, 2), 0) self.edit_mode = None else: # a line was drawn interactively self.drawing = append(self.drawing, self.drawn.reshape(-1, 2, 2), 0) if func: func(self.drawing) if oneshot: self.accept_drawing() if func and not self.drawing_accepted: func(self.drawing) self.finish_drawing() return self.drawing
[docs] def start_drawing(self, mode): """Start an interactive line drawing mode.""" pf.debug("START DRAWING MODE", pf.DEBUG.GUI) self.mousehandler.set(LEFT, NONE, self.mouse_draw_line) self.mousehandler.set(RIGHT, NONE, self.emit_done) self.mousehandler.set(RIGHT, SHIFT, self.emit_cancel) self.mousehandler.setCursorShape('default') self.signals.DONE.connect(self.accept_drawing) self.signals.CANCEL.connect(self.cancel_drawing) self.drawing_mode = mode self.edit_mode = None self.drawing = empty((0, 2, 2), dtype=int)
[docs] def wait_drawing(self): """Wait for the user to interactively draw a line.""" self.drawing_timer = QtCore.QThread self.drawing_busy = True while self.drawing_busy: self.drawing_timer.msleep(20) pf.app.processEvents()
[docs] def finish_drawing(self): """End an interactive drawing mode.""" pf.debug("END DRAWING MODE", pf.DEBUG.GUI) self.mousehandler.reset(LEFT, NONE) self.mousehandler.reset(RIGHT, NONE) self.mousehandler.reset(RIGHT, SHIFT) self.mousehandler.setCursorShape('default') self.signals.DONE.disconnect(self.accept_drawing) self.signals.CANCEL.disconnect(self.cancel_drawing) self.drawing_mode = None
[docs] def accept_drawing(self, clear=False): """Cancel an interactive drawing mode. If clear == True, the current drawing is cleared. """ pf.debug("CANCEL DRAWING MODE", pf.DEBUG.GUI) self.drawing_accepted = True if clear: self.drawing = empty((0, 2, 2), dtype=int) self.drawing_accepted = False self.drawing_canceled = True self.drawing_busy = False
[docs] def cancel_drawing(self): """Cancel an interactive drawing mode and clear the drawing.""" self.accept_drawing(clear=True)
[docs] def edit_drawing(self, mode): """Edit an interactive drawing.""" self.edit_mode = mode self.drawing_busy = False
######## QtOpenGL interface ##############################
[docs] def initializeGL(self): self.glinit() self.initCamera() self.resizeGL(self.width(), self.height()) self.makeCurrent()
#self.setCamera()
[docs] def resizeGL(self, w, h): self.setSize(w, h)
[docs] def paintGL(self): if not self.mode2D: self.display()
####### MOUSE EVENT HANDLERS ############################ # Mouse functions can be bound to any of the mouse buttons # LEFT, MIDDLE or RIGHT. # Each mouse function should accept three possible actions: # PRESS, MOVE, RELEASE. # On a mouse button PRESS, the mouse screen position and the pressed # button are always saved in self.statex,self.statey,self.button. # The mouse function does not need to save these and can directly use # their values. # On a mouse button RELEASE, self.button is cleared, to avoid further # move actions. # ATTENTION! The y argument is positive upwards, as in normal OpenGL # operations!
[docs] def dynarot(self, x, y, action): """Perform dynamic rotation operation. This function processes mouse button events controlling a dynamic rotation operation. The action is one of PRESS, MOVE or RELEASE. """ if action == PRESS: w, h = self.getSize() self.state = [self.statex-w/2, self.statey-h/2] self.stated = length(self.state) < 0.35 * length([w, h]) elif action == MOVE: w, h = self.getSize() # set all three rotations from mouse movement # tangential movement sets twist, # but only if initial vector is big enough x0 = self.state # initial vector d = length(x0) x1 = [x-w/2, y-h/2] # new vector if d > h/8: a0 = math.atan2(x0[0], x0[1]) a1 = math.atan2(x1[0], x1[1]) an = (a1-a0) / math.pi * 180 ds = stuur(d, [-h/4, h/8, h/4], [-1, 0, 1], 2) twist = - an*ds self.camera.rotate(twist, 0., 0., 1.) self.state = x1 # radial movement rotates around vector in lens plane x0 = [self.statex-w/2, self.statey-h/2] # initial vector if x0 == [0., 0.]: x0 = [1., 0.] dx = [x-self.statex, y-self.statey] # movement b = projection(dx, x0) if abs(b) > 5: # only process when the movement is large enough if self.stated: # mouse action did not start in the corners val = stuur(b, [-2*h, 0, 2*h], [-180, 0, +180], 1) rot = [abs(val), -dx[1], dx[0], 0] self.camera.rotate(*rot) self.statex, self.statey = (x, y) self.update() elif action == RELEASE: self.update()
[docs] def dynapan(self, x, y, action): """Perform dynamic pan operation. This function processes mouse button events controlling a dynamic pan operation. The action is one of PRESS, MOVE or RELEASE. """ if action == PRESS: pass elif action == MOVE: w, h = self.getSize() dx, dy = float(self.statex-x)/w, float(self.statey-y)/h self.camera.transArea(dx, dy) self.statex, self.statey = (x, y) self.update() elif action == RELEASE: self.update()
[docs] def dynazoom(self, x, y, action): """Perform dynamic zoom operation. This function processes mouse button events controlling a dynamic zoom operation. The action is one of PRESS, MOVE or RELEASE. """ if action == PRESS: self.state = [self.camera.dist, self.camera.area.tolist(), pf.cfg['gui/dynazoom']] elif action == MOVE: w, h = self.getSize() dx, dy = float(self.statex-x)/w, float(self.statey-y)/h for method, state, value, size in zip(self.state[2], [self.statex, self.statey], [x, y], [w, h]): if method == 'area': d = float(state-value)/size f = math.exp(4*d) self.camera.zoomArea(f, area=np.asarray(self.state[1]).reshape(2, 2)) elif method == 'dolly': d = stuur(value, [0, state, size], [5, 1, 0.2], 1.2) self.camera.dist = d*self.state[0] self.update() elif action == RELEASE: self.update()
[docs] def wheel_zoom(self, delta): """Zoom by rotating a wheel over an angle delta""" f = 2**(delta/120.*pf.cfg['gui/wheelzoomfactor']) if pf.cfg['gui/wheelzoom'] == 'area': self.camera.zoomArea(f) elif pf.cfg['gui/wheelzoom'] == 'lens': self.camera.zoom(f) else: self.camera.dolly(f) self.update()
[docs] def emit_done(self, x, y, action): """Emit a DONE event by clicking the mouse. This is equivalent to pressing the ENTER button.""" if action == RELEASE: self.signals.DONE.emit()
[docs] def emit_cancel(self, x, y, action): """Emit a CANCEL event by clicking the mouse. This is equivalent to pressing the ESC button.""" if action == RELEASE: self.signals.CANCEL.emit()
@classmethod def has_modifier(clas, e, mod): return (e.modifiers() & mod) == mod
[docs] def mousePressEvent(self, e): """Process a mouse press event.""" # Make the clicked viewport the current one pf.GUI.viewports.setCurrent(self) # on PRESS, always remember mouse position and button self.statex, self.statey = e.x(), self.height()-e.y() self.button = e.button() self.mod = e.modifiers() & ALLMODS func = self.mousehandler.get(self.button, self.mod) if func: func(self.statex, self.statey, PRESS) e.accept()
[docs] def mouseMoveEvent(self, e): """Process a mouse move event.""" # the MOVE event does not identify a button, use the saved one func = self.mousehandler.get(self.button, self.mod) if func: #print(f"{func=}") func(e.x(), self.height()-e.y(), MOVE) e.accept()
[docs] def mouseReleaseEvent(self, e): """Process a mouse release event.""" func = self.mousehandler.get(self.button, self.mod) self.button = None # clear the stored button if func: func(e.x(), self.height()-e.y(), RELEASE) e.accept()
[docs] def wheelEvent(self, e): """Process a wheel event.""" func = self.wheel_zoom if func: func(e.delta()) e.accept()
# Any keypress with focus in the canvas generates a GUI WAKEUP signal. # This is used to break out of a wait status. # Events not handled here could also be handled by the toplevel # event handler.
[docs] def keyPressEvent(self, e): # Make the clicked viewport the current one pf.GUI.signals.WAKEUP.emit() if e.key() == ESC: self.signals.CANCEL.emit() e.accept() elif e.key() == ENTER or e.key() == RETURN: self.signals.DONE.emit() e.accept() else: e.ignore()
# End