#
##
##  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