Mouse to world coordinates [WORKING]

For anyone that has wanted to turn mouse coordinates into world coordinates the code is below.

The code below manipulates only scatter and lines currently. I have not messed around with it for other plot types.

I found it easier to move things around with the mouse by locking the axis you want to move along by pressing and holding either the x, y or z key on your keyboard. It’s a little bit hard to visualize where the point is actually moving to and to actually move it to that location when something is moving along all 3 axis at the same time.

I also changed the default mouse functionality, you the comments at the top of the code as it will explain how to use the new functionality.

The code below uses wxPython for a backend, I am sure it can be easily adapted to other backends without much trouble.

The works with any zoom and any rotation of the plot. It would be nice to have this functionality built into matplotlib as I am sure there can probably be greater accuracy that is able to be achieved than the way I am going about it. I am sure if I convert the python code to C code and compile it using Cython it will run a lot faster. I would also need to convert the matplotlib Python source files to get the most in terms of performance.

"""
Press and hold `x`, `y` or `z` and then click on one of the points in the plot
and drag the point along the axis noted by the key that is being held down.

Left click and drag rotates the plot.
Right click and drag pans the plot.
Mouse wheel zooms the plot.

Code was added to remove the flicking that was seen when the plot is redrawn.
"""

import wx
import matplotlib

matplotlib.rcParams[f'axes3d.xaxis.panecolor'] = (0.0, 0.0, 0.0, 0.0)
matplotlib.rcParams[f'axes3d.yaxis.panecolor'] = (0.0, 0.0, 0.0, 0.0)
matplotlib.rcParams[f'axes3d.yaxis.panecolor'] = (0.0, 0.0, 0.0, 0.0)
matplotlib.rcParams['grid.color'] = (0.5, 0.5, 0.5, 0.5)
matplotlib.rcParams['grid.linewidth'] = 0.5
matplotlib.rcParams['grid.linestyle'] = ':'
matplotlib.rcParams['axes.linewidth'] = 0.5
matplotlib.rcParams['axes.edgecolor'] = (0.45, 0.45, 0.45, 0.55)

matplotlib.rcParams['xtick.major.width'] = 0.5
matplotlib.rcParams['ytick.major.width'] = 0.5

matplotlib.rcParams['ytick.minor.width'] = 0.5
matplotlib.rcParams['ytick.minor.width'] = 0.5


matplotlib.use('WXAgg')


from matplotlib.backends.backend_wxagg import (  # NOQA
    FigureCanvasWxAgg as FigureCanvas
)
from matplotlib.backends.backend_wxagg import (  # NOQA
    NavigationToolbar2WxAgg as NavigationToolbar
)
from mpl_toolkits.mplot3d.art3d import Line3D, Path3DCollection  # NOQA
from mpl_toolkits.mplot3d import axes3d  # NOQA
from matplotlib.backend_bases import ResizeEvent  # NOQA
from mpl_toolkits.mplot3d.axes3d import _Quaternion  # NOQA
import matplotlib.pyplot  # NOQA
import numpy as np  # NOQA

from decimal import Decimal as decimal


class Canvas(FigureCanvas):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.Bind(wx.EVT_ERASE_BACKGROUND, self._on_erase_background)

    # bypass erasing the background when the plot is redrawn.
    # This helps to eliminate the flicker that is seen when a redraw occurs.
    # the second piece needed to eliminate the flicker is seen below
    def _on_erase_background(self, _):
        pass

    # override the _on_paint method in the canvas
    # this is done so double buffer is used which eliminates the flicker
    # that is seen when the plot redraws
    def _on_paint(self, event):
        drawDC = wx.BufferedPaintDC(self)
        if not self._isDrawn:
            self.draw(drawDC=drawDC)
        else:
            self.gui_repaint(drawDC=drawDC)
        drawDC.Destroy()

    # override the _on_size method in the canvas
    def _on_size(self, event):
        self._update_device_pixel_ratio()
        sz = self.GetParent().GetSizer()
        if sz:
            si = sz.GetItem(self)
        else:
            si = None

        if sz and si and not si.Proportion and not si.Flag & wx.EXPAND:
            size = self.GetMinSize()
        else:
            size = self.GetClientSize()
            size.IncTo(self.GetMinSize())

        if getattr(self, "_width", None):
            if size == (self._width, self._height):
                return

        self._width, self._height = size
        self._isDrawn = False

        if self._width <= 1 or self._height <= 1:
            return

        dpival = self.figure.dpi

        if wx.Platform != '__WXMSW__':
            scale = self.GetDPIScaleFactor()
            dpival /= scale

        winch = self._width / dpival
        hinch = self._height / dpival
        self.figure.set_size_inches(winch, hinch, forward=False)

        self.Refresh(eraseBackground=False)
        ResizeEvent("resize_event", self)._process()  # NOQA
        self.draw_idle()


# monkey patch the _on_move method for Axes3D
# this is done to change the handling of the right mouse button.
# the right mouse button pans the plot instead of zooming.
# The mouse wheel is used to zoom instead (as it should be)
def _on_move(self, event):
    if not self.button_pressed or event.key:
        return

    if self.get_navigate_mode() is not None:
        return

    if self.M is None:
        return

    x, y = event.xdata, event.ydata

    if x is None or event.inaxes != self:
        return

    dx, dy = x - self._sx, y - self._sy
    w = self._pseudo_w
    h = self._pseudo_h

    if self.button_pressed in self._rotate_btn:
        if dx == 0 and dy == 0:
            return

        style = matplotlib.rcParams['axes3d.mouserotationstyle']
        if style == 'azel':
            roll = np.deg2rad(self.roll)

            delev = (-(dy / h) * 180 * np.cos(roll) +
                     (dx / w) * 180 * np.sin(roll))

            dazim = (-(dy / h) * 180 * np.sin(roll) -
                     (dx / w) * 180 * np.cos(roll))

            elev = self.elev + delev
            azim = self.azim + dazim
            roll = self.roll
        else:
            q = _Quaternion.from_cardan_angles(
                *np.deg2rad((self.elev, self.azim, self.roll)))

            if style == 'trackball':
                k = np.array([0, -dy / h, dx / w])
                nk = np.linalg.norm(k)
                th = nk / matplotlib.rcParams['axes3d.trackballsize']
                dq = _Quaternion(np.cos(th), k * np.sin(th) / nk)
            else:  # 'sphere', 'arcball'
                current_vec = self._arcball(self._sx / w, self._sy / h)
                new_vec = self._arcball(x / w, y / h)
                if style == 'sphere':
                    dq = _Quaternion.rotate_from_to(current_vec, new_vec)
                else:  # 'arcball'
                    dq = (_Quaternion(0, new_vec) *
                          _Quaternion(0, -current_vec))

            q = dq * q
            elev, azim, roll = np.rad2deg(q.as_cardan_angles())

        vertical_axis = self._axis_names[self._vertical_axis]

        self.view_init(elev=elev, azim=azim, roll=roll,
                       vertical_axis=vertical_axis, share=True)

        self.stale = True

    elif self.button_pressed in self._zoom_btn:
        px, py = self.transData.transform([self._sx, self._sy])
        self.start_pan(px, py, 2)
        self.drag_pan(2, None, event.x, event.y)
        self.end_pan()

    self._sx, self._sy = x, y
    self.get_figure(root=True).canvas.draw_idle()


setattr(axes3d.Axes3D, '_on_move', _on_move)


class Frame(wx.Frame):
    def __init__(self):
        self.key = None
        self.selected_object = None
        self.button_held = False
        self.had_motion = False
        self.object_tooltip = None

        self.wires = []
        self.artists_mapping = {}
        self._offset = None
        
        self.data = [
            [10, 20, -30, 'o'],
            [15, 15, 0, 'o'],
            [20, 15, -20, 'o'],
            [100, 50, 25, 'o'],
            [100, 50, -10, 'o'],
            [2, 0, 0, 'o'],
            [5, 20, -30, 'o']

        ]

        self.lines = [
            [[10, 15], [20, 15], [-30, 0], 3],
            [[15, 20], [15, 15], [0, -20], 3],
            [[100, 100], [50, 50], [25, -10], 1],
            [[2, 5], [0, 20], [0, -30], 2],
        ]

        super().__init__(None, size=(1000, 700))
        panel = wx.Panel(self)

        v_sizer = wx.BoxSizer(wx.VERTICAL)
        h_sizer = wx.BoxSizer(wx.HORIZONTAL)

        self.fig = matplotlib.pyplot.figure()

        ax = self.axes = self.fig.add_subplot(projection='3d')
        ax.autoscale(True)

        for line in self.data:
            obj = ax.scatter(*line[:-1], marker=line[-1], picker=True)
            self.artists_mapping[obj] = line

        for line in self.lines:
            wire = Line3D(*line[:-1], linewidth=line[-1], picker=True)
            ax.add_line(wire)
            self.wires.append(wire)

        # Set the axis labels
        ax.set_xlabel('x')
        ax.set_ylabel('y')
        ax.set_zlabel('z')

        self.canvas = Canvas(panel, wx.ID_ANY, self.fig)

        # MouseEvent
        self.canvas.mpl_connect("button_press_event", self.on_press)
        self.canvas.mpl_connect("motion_notify_event", self.on_motion)
        self.canvas.mpl_connect("button_release_event", self.on_release)
        self.canvas.mpl_connect("scroll_event", self.on_mouse_scroll)

        # LocationEvent
        self.canvas.mpl_connect("figure_enter_event", self.on_figure_enter)
        self.canvas.mpl_connect("figure_leave_event", self.on_figure_leave)
        self.canvas.mpl_connect("axes_enter_event", self.on_axes_enter)
        self.canvas.mpl_connect("axes_leave_event", self.on_axes_leave)

        # KeyEvent
        self.canvas.mpl_connect("key_press_event", self.on_key_press)
        self.canvas.mpl_connect("key_release_event", self.on_key_release)

        # CloseEvent
        self.canvas.mpl_connect("close_event", self.on_close)

        # DrawEvent
        self.canvas.mpl_connect("draw_event", self.on_draw)

        # PickEvent
        self.canvas.mpl_connect("pick_event", self.on_pick)

        # ResizeEvent
        self.canvas.mpl_connect("resize_event", self.on_resize)

        toolbar = NavigationToolbar(self.canvas)
        toolbar.Realize()

        h_sizer.Add(self.canvas, 1, wx.EXPAND | wx.ALL, 5)
        v_sizer.Add(h_sizer, 1, wx.EXPAND)
        v_sizer.Add(toolbar, 0, wx.LEFT | wx.EXPAND)

        panel.SetSizer(v_sizer)
        toolbar.update()

        def _do():
            for value in self.axes._axis_map.values():
                value.set_ticks_position('both')
                value.set_label_position('both')
            self.canvas.draw()

        wx.CallAfter(_do)

    def on_close(self, evt):
        pass

    def on_draw(self, evt):
        pass

    def on_key_press(self, evt):
        key = evt.key
        if isinstance(key, str):
            self.key = key.lower()

    def on_key_release(self, evt):
        key = evt.key
        if isinstance(key, str) and key.lower() == self.key:
            self.key = None

    def on_pick(self, evt):
        if isinstance(evt.artist, Path3DCollection):
            self.selected_object = evt.artist
            self.axes.button_pressed = None

    def on_resize(self, evt):
        pass

    def on_mouse_scroll(self, evt):
        x, y = evt.xdata, evt.ydata

        if x is None or evt.inaxes != self.axes:
            return

        if not hasattr(self.axes, '_sx') or not hasattr(self.axes, '_sy'):
            self.axes._sx, self.axes._sy = x, y

        h = self.axes._pseudo_h  # NOQA

        scale = h / (h + (evt.step / 100))
        self.axes._scale_axis_limits(scale, scale, scale)  # NOQA

        self.axes.get_figure(root=True).canvas.draw_idle()

    def on_figure_enter(self, evt):
        pass

    def on_figure_leave(self, evt):
        pass

    def on_axes_enter(self, evt):
        pass

    def on_axes_leave(self, evt):
        pass

    def on_press(self, event):
        if event.button == 1:
            # left button
            if event.dblclick:
                pass
            elif self.selected_object is not None:
                self.button_held = True
                self.axes.button_pressed = None

        elif event.button == 2:
            # middle button
            pass
        elif event.button == 3:
            # right button
            # open context menu
            self.had_motion = False

        elif event.button == 8:
            # forward
            pass
        elif event.button == 9:
            # back
            pass

    def location_coords(self, xv, yv):
        p1, pane_idx = self.axes._calc_coord(xv, yv, None)  # NOQA
        xs = self.axes.format_xdata(p1[0])
        ys = self.axes.format_ydata(p1[1])
        zs = self.axes.format_zdata(p1[2])

        def get_float(val):
            if val.startswith('−'):
                return decimal(str(-float(val[1:])))
            else:
                return decimal(str(float(val)))

        return get_float(xs), get_float(ys), get_float(zs)

    def on_motion(self, event):
        if event.button == 3:
            self.had_motion = True
            # Start the pan event with pixel coordinates

            x, y = event.xdata, event.ydata
            # In case the mouse is out of bounds.
            if x is None or event.inaxes != self:
                return

            self.axes.button_pressed = None

            if not hasattr(self.axes, '_sx') or not hasattr(self.axes, '_sy'):
                self.axes._sx, self.axes._sy = event.xdata, event.ydata

            px, py = self.axes.transData.transform([self.axes._sx, self.axes._sy])  # NOQA
            self.axes.start_pan(px, py, 2)
            # pan view (takes pixel coordinate input)
            self.axes.drag_pan(2, None, event.x, event.y)
            self.axes.end_pan()

            self.axes._sx, self.axes._sy = event.xdata, event.ydata
            # Always request a draw update at the end of interaction
            self.axes.get_figure(root=True).canvas.draw_idle()

        elif self.button_held and self.selected_object is not None:
            if self.key not in ('x', 'y', 'z'):
                return

            try:
                x, y, z = self.location_coords(event.xdata, event.ydata)
            except TypeError:
                return

            old_pos = self.artists_mapping[self.selected_object]

            old_x, old_y, old_z = old_pos[:-1]

            if self._offset is None:
                if self.key == 'x':
                    self._offset = decimal(str(old_x)) - x
                elif self.key == 'y':
                    self._offset = decimal(str(old_y)) - y
                else:  # 'z'
                    self._offset = decimal(str(old_z)) - z

                return

            # self.axes.autoscale(False)

            if self.key == 'x':
                x += self._offset
            elif self.key == 'y':
                y += self._offset
            elif self.key == 'z':
                z += self._offset

            for wire in self.wires:
                xs, ys, zs = wire.get_data_3d()

                if xs[0] == old_x and ys[0] == old_y and zs[0] == old_z:
                    if self.key == 'x':
                        xs[0] = float(x)
                    elif self.key == 'y':
                        ys[0] = float(y)
                    elif self.key == 'z':
                        zs[0] = float(z)

                    wire.set_data_3d(xs, ys, zs)

                elif xs[1] == old_x and ys[1] == old_y and zs[1] == old_z:
                    if self.key == 'x':
                        xs[1] = float(x)
                    elif self.key == 'y':
                        ys[1] = float(y)
                    elif self.key == 'z':
                        zs[1] = float(z)

                    wire.set_data_3d(xs, ys, zs)

            if self.key == 'x':
                old_pos[0] = float(x)
                self.selected_object._offsets3d[0][0] = float(x)  # NOQA
            elif self.key == 'y':
                old_pos[1] = float(y)
                self.selected_object._offsets3d[1][0] = float(y)  # NOQA
            elif self.key == 'z':
                old_pos[2] = float(z)
                self.selected_object._offsets3d[2][0] = float(z)  # NOQA

            self.axes.get_figure(root=True).canvas.draw_idle()

            height = self.canvas.GetSize()[1]
            x = round(x, 2)
            y = round(y, 2)
            z = round(z, 2)
            label = f'x: {x} y: {y} z: {z}'

            size = self.canvas.GetTextExtent(label)
            if self.object_tooltip is None:
                self.object_tooltip = wx.StaticText(self.canvas, wx.ID_ANY,
                                                    label=label, size=size,
                                                    pos=(0, height - size[1]))
            else:
                self.object_tooltip.SetLabel(label)
                self.object_tooltip.SetPosition((0, height - size[1]))
                self.object_tooltip.SetSize(size)

    def on_release(self, event):
        if self.object_tooltip is not None:
            self.object_tooltip.Destroy()
            self.object_tooltip = None
            self._offset = None

        if event.button == 3 and not self.had_motion:
            menu = wx.Menu()

            wx.MenuItem()

            menu.Append(wx.ID_ANY, 'menu item 1')
            menu.Append(wx.ID_ANY, 'menu item 2')
            menu.Append(wx.ID_ANY, 'menu item 3')
            menu.AppendSeparator()
            menu.Append(wx.ID_ANY, 'menu item 4')
            menu.Append(wx.ID_ANY, 'menu item 5')
            menu.AppendSeparator()

            sub_menu = wx.Menu()
            sub_menu.Append(wx.ID_ANY, 'sub menu item 1')
            sub_menu.Append(wx.ID_ANY, 'sub menu item 2')
            sub_menu.Append(wx.ID_ANY, 'sub menu item 3')
            sub_menu.AppendSeparator()
            sub_menu.Append(wx.ID_ANY, 'sub menu item 4')
            sub_menu.Append(wx.ID_ANY, 'sub menu item 5')

            menu.AppendSubMenu(sub_menu, 'menu item 6')

            x = event.x
            y = event.y

            height = self.canvas.GetSize()[1]

            y = abs(y - height)

            self.canvas.PopupMenu(menu, x, y)
        else:
            self.selected_object = None
            self.button_held = False

        self.had_motion = False


class App(wx.App):
    def OnInit(self):
        frame = Frame()
        frame.Show()
        return True


if __name__ == "__main__":
    app = App()
    app.MainLoop()

1 Like