Files
flatcam-wsl/appGUI/PlotCanvas.py
Marius Stanciu a3f9d7c248 - added ability to change the mouse cursor color on the fly
- in Distance Plugin made sure that the 'big cursor' (when is used) is black in color (visible in most situations)
- in Distance Plugin, for the 2D graphic mode activated the utility line
2022-03-29 02:14:44 +03:00

716 lines
26 KiB
Python

# ##########################################################
# FlatCAM: 2D Post-processing for Manufacturing #
# Author: Dennis Hayrullin (c) #
# Date: 2016 #
# MIT Licence #
# ##########################################################
from PyQt6 import QtCore, QtGui
import logging
from appGUI.VisPyCanvas import VisPyCanvas, Color
from appGUI.VisPyVisuals import ShapeGroup, ShapeCollection, TextCollection, TextGroup, Cursor
from vispy.scene.visuals import InfiniteLine, Line, Rectangle, Text
import gettext
import appTranslation as fcTranslate
import builtins
import numpy as np
from vispy.geometry import Rect
fcTranslate.apply_language('strings')
if '_' not in builtins.__dict__:
_ = gettext.gettext
log = logging.getLogger('base')
class PlotCanvas(QtCore.QObject, VisPyCanvas):
"""
Class handling the plotting area in the application.
"""
def __init__(self, fcapp):
"""
The constructor configures the VisPy figure that
will contain all plots, creates the base axes and connects
events to the plotting area.
:rtype: PlotCanvas
"""
# super(PlotCanvas, self).__init__()
# QtCore.QObject.__init__(self)
# VisPyCanvas.__init__(self)
super().__init__()
# VisPyCanvas does not allow new attributes. Override.
self.unfreeze()
self.fcapp = fcapp
settings = QtCore.QSettings("Open Source", "FlatCAM")
if settings.contains("theme"):
theme = settings.value('theme', type=str)
else:
theme = 'white'
if theme == 'white':
self.line_color = (0.3, 0.0, 0.0, 1.0)
# self.rect_hud_color = Color('#0000FF10')
self.rect_hud_color = Color('#80808040')
self.text_hud_color = 'black'
else:
self.line_color = (0.4, 0.4, 0.4, 1.0)
self.rect_hud_color = Color('#80808040')
self.text_hud_color = 'white'
# workspace lines; I didn't use the rectangle because I didn't want to add another VisPy Node,
# which might decrease performance
# self.b_line, self.r_line, self.t_line, self.l_line = None, None, None, None
self.workspace_line = None
self.pagesize_dict = {}
self.pagesize_dict.update(
{
'A0': (841, 1189),
'A1': (594, 841),
'A2': (420, 594),
'A3': (297, 420),
'A4': (210, 297),
'A5': (148, 210),
'A6': (105, 148),
'A7': (74, 105),
'A8': (52, 74),
'A9': (37, 52),
'A10': (26, 37),
'B0': (1000, 1414),
'B1': (707, 1000),
'B2': (500, 707),
'B3': (353, 500),
'B4': (250, 353),
'B5': (176, 250),
'B6': (125, 176),
'B7': (88, 125),
'B8': (62, 88),
'B9': (44, 62),
'B10': (31, 44),
'C0': (917, 1297),
'C1': (648, 917),
'C2': (458, 648),
'C3': (324, 458),
'C4': (229, 324),
'C5': (162, 229),
'C6': (114, 162),
'C7': (81, 114),
'C8': (57, 81),
'C9': (40, 57),
'C10': (28, 40),
# American paper sizes
'LETTER': (8.5*25.4, 11*25.4),
'LEGAL': (8.5*25.4, 14*25.4),
'ELEVENSEVENTEEN': (11*25.4, 17*25.4),
# From https://en.wikipedia.org/wiki/Paper_size
'JUNIOR_LEGAL': (5*25.4, 8*25.4),
'HALF_LETTER': (5.5*25.4, 8*25.4),
'GOV_LETTER': (8*25.4, 10.5*25.4),
'GOV_LEGAL': (8.5*25.4, 13*25.4),
'LEDGER': (17*25.4, 11*25.4),
}
)
# <VisPyCanvas>
self.create_native()
self.native.setParent(self.fcapp.ui)
axis_default_color = self.fcapp.options['global_axis_color']
self.axis_transparency = 0.8
axis_color = self.color_hex2tuple(axis_default_color)
axis_color = axis_color[0], axis_color[1], axis_color[2], self.axis_transparency
# ## AXIS # ##
# self.v_line = InfiniteLine(pos=0, color=(0.70, 0.3, 0.3, 0.8), vertical=True,
# parent=self.view.scene)
self.v_line = InfiniteLine(pos=0, color=axis_color, vertical=True, line_width=1.5,
parent=None)
self.h_line = InfiniteLine(pos=0, color=axis_color, vertical=False, line_width=1.5,
parent=None)
self.line_parent = None
if self.fcapp.options["global_cursor_color_enabled"]:
c_color = Color(self.fcapp.options["global_cursor_color"]).rgba
else:
c_color = self.line_color
self.cursor_v_line = InfiniteLine(pos=None, color=c_color, vertical=True,
parent=self.line_parent)
self.cursor_h_line = InfiniteLine(pos=None, color=c_color, vertical=False,
parent=self.line_parent)
# setup HUD
# TEXT HUD
self.text_hud = Text('', color=self.text_hud_color, method='gpu', anchor_x='left', parent=None)
# RECT HUD
self.rect_hud = Rectangle(width=10, height=10, radius=[5, 5, 5, 5], center=(20, 20),
border_color=self.rect_hud_color, color=self.rect_hud_color, parent=None)
self.rect_hud.set_gl_state(depth_test=False)
self.on_update_text_hud()
# draw a rectangle made out of 4 lines on the canvas to serve as a hint for the work area
# all CNC have a limited workspace
if self.fcapp.options['global_workspace'] is True:
self.draw_workspace(workspace_size=self.fcapp.options["global_workspaceT"])
# HUD Display
self.hud_enabled = False
# enable the HUD if it is activated in FlatCAM Preferences
if self.fcapp.options['global_hud'] is True:
self.on_toggle_hud(state=True, silent=True)
# Axis Display
self.axis_enabled = False
# enable Axis
if self.fcapp.options['global_axis'] is True:
self.on_toggle_axis(state=True, silent=True)
# enable Grid lines
self.grid_lines_enabled = True
self.shape_collections = []
self.shape_collection = self.new_shape_collection()
self.fcapp.pool_recreated.connect(self.on_pool_recreated)
self.text_collection = self.new_text_collection()
self.text_collection.enabled = True
# Mouse Custom Cursor
self.c = None
self.big_cursor = None
self._cursor_color = self.fcapp.cursor_color_3D
# Parent container
# self.container = container
# Keep VisPy canvas happy by letting it be "frozen" again.
self.freeze()
# fit everything into view
self.fit_view()
self.graph_event_connect('mouse_wheel', self.on_mouse_scroll)
# <QtCore.QObject>
# self.container.addWidget(self.native)
@staticmethod
def color_hex2tuple(hex_color):
# strip the # from the beginning
color = hex_color[1:]
# convert color RGB components from range 0...255 to 0...1
r_color = int(color[:2], 16) / 255
g_color = int(color[2:4], 16) / 255
b_color = int(color[4:6], 16) / 255
return r_color, g_color, b_color
def on_toggle_axis(self, signal=None, state=None, silent=None):
if not state:
state = not self.axis_enabled
if state:
self.axis_enabled = True
self.fcapp.defaults['global_axis'] = True
self.v_line.parent = self.view.scene
self.h_line.parent = self.view.scene
self.fcapp.ui.axis_status_label.setStyleSheet("""
QLabel
{
color: black;
background-color: orange;
}
""")
if silent is None:
self.fcapp.inform[str, bool].emit(_("Axis enabled."), False)
else:
self.axis_enabled = False
self.fcapp.defaults['global_axis'] = False
self.v_line.parent = None
self.h_line.parent = None
self.fcapp.ui.axis_status_label.setStyleSheet("")
if silent is None:
self.fcapp.inform[str, bool].emit(_("Axis disabled."), False)
def apply_axis_color(self):
self.fcapp.log.debug('PlotCanvas.apply_axis_color() -> axis color applied')
axis_default_color = self.fcapp.options['global_axis_color']
axis_color = self.color_hex2tuple(axis_default_color)
axis_color = axis_color[0], axis_color[1], axis_color[2], self.axis_transparency
if axis_color is not None:
axis_color = np.array(axis_color, dtype=np.float32)
if axis_color.ndim != 1 or axis_color.shape[0] != 4:
self.fcapp.log.error('axis color must be a 4 element float rgba tuple,'
' list or array')
self.v_line._color = axis_color
self.v_line._changed['color'] = True
self.h_line._color = axis_color
self.h_line._changed['color'] = True
def on_toggle_hud(self, signal=None, state=None, silent=None):
if state is None:
state = not self.hud_enabled
if state:
self.hud_enabled = True
self.rect_hud.parent = self.view
self.text_hud.parent = self.view
self.fcapp.defaults['global_hud'] = True
self.fcapp.ui.hud_label.setStyleSheet("""
QLabel
{
color: black;
background-color: mediumpurple;
}
""")
if silent is None:
self.fcapp.inform[str, bool].emit(_("HUD enabled."), False)
else:
self.hud_enabled = False
self.rect_hud.parent = None
self.text_hud.parent = None
self.fcapp.defaults['global_hud'] = False
self.fcapp.ui.hud_label.setStyleSheet("")
if silent is None:
self.fcapp.inform[str, bool].emit(_("HUD disabled."), False)
def on_update_text_hud(self, dx=None, dy=None, x=None, y=None):
"""
Update the text of the location labels from HUD
:param x: X location
:type x: float
:param y: Y location
:type y: float
:param dx: Delta X location
:type dx: float
:param dy: Delta Y location
:type dy: float
:return:
:rtype: None
"""
# units
units = self.fcapp.app_units.lower()
dx_dec = str(self.fcapp.dec_format(dx, self.fcapp.decimals)) if dx else '0.0'
dy_dec = str(self.fcapp.dec_format(dy, self.fcapp.decimals)) if dy else '0.0'
x_dec = str(self.fcapp.dec_format(x, self.fcapp.decimals)) if x else '0.0'
y_dec = str(self.fcapp.dec_format(y, self.fcapp.decimals)) if y else '0.0'
l1_hud_text = 'Dx: %s [%s]' % (dx_dec, units)
l2_hud_text = 'Dy: %s [%s]' % (dy_dec, units)
l3_hud_text = 'X: %s [%s]' % (x_dec, units)
l4_hud_text = 'Y: %s [%s]' % (y_dec, units)
hud_text = '%s\n%s\n\n%s\n%s' % (l1_hud_text, l2_hud_text, l3_hud_text, l4_hud_text)
# font size
qsettings = QtCore.QSettings("Open Source", "FlatCAM")
if qsettings.contains("hud_font_size"):
fsize = qsettings.value('hud_font_size', type=int)
else:
fsize = 8
try:
c_font = QtGui.QFont("times", fsize)
except Exception:
# maybe Unix-like OS's don't have the Times font installed, use whatever is available
c_font = QtGui.QFont()
c_font.setPointSize(fsize)
c_font_metrics = QtGui.QFontMetrics(c_font)
l1_length = c_font_metrics.horizontalAdvance('Dx:xxx[mm]') + c_font_metrics.horizontalAdvance(str(dx_dec))
l2_length = c_font_metrics.horizontalAdvance('Dy:xxx[mm]') + c_font_metrics.horizontalAdvance(str(dy_dec))
l3_length = c_font_metrics.horizontalAdvance('X:xxxxx[mm]') + c_font_metrics.horizontalAdvance(str(x_dec))
l4_length = c_font_metrics.horizontalAdvance('Y:xxxxx[mm]') + c_font_metrics.horizontalAdvance(str(y_dec))
# l1_length = c_font_metrics.boundingRect(l1_hud_text).width()
# l2_length = c_font_metrics.boundingRect(l2_hud_text).width()
# l3_length = c_font_metrics.boundingRect(l3_hud_text).width()
# l4_length = c_font_metrics.boundingRect(l4_hud_text).width()
l1_height = c_font_metrics.boundingRect(l1_hud_text).height()
# print(self.fcapp.qapp.devicePixelRatio())
# coordinates and anchors
height = (5 * l1_height) + c_font_metrics.lineSpacing() * 1.5 + 10
width = max(l1_length, l2_length, l3_length, l4_length) * 1.3 # don't know where the 1.3 comes
center_x = (width / 2) + 5
center_y = (height / 2) + 5
# text
self.text_hud.font_size = fsize
self.text_hud.text = hud_text
self.text_hud.pos = 10, center_y
self.text_hud.anchors = 'left', 'center'
# rectangle
self.rect_hud.center = center_x, center_y
self.rect_hud.width = width
self.rect_hud.height = height
self.rect_hud.radius = [5, 5, 5, 5]
def on_toggle_grid_lines(self, signal=None, silent=None):
state = not self.grid_lines_enabled
settings = QtCore.QSettings("Open Source", "FlatCAM")
if settings.contains("theme"):
theme = settings.value('theme', type=str)
else:
theme = 'white'
if theme == 'white':
color = 'dimgray'
else:
color = '#dededeff'
if state:
self.fcapp.defaults['global_grid_lines'] = True
self.grid_lines_enabled = True
# self.grid.parent = self.view.scene
self.grid._grid_color_fn['color'] = Color(color).rgba
if silent is None:
self.fcapp.inform[str, bool].emit(_("Grid enabled."), False)
else:
self.fcapp.defaults['global_grid_lines'] = False
self.grid_lines_enabled = False
# self.grid.parent = None
self.grid._grid_color_fn['color'] = Color('#FFFFFFFF').rgba
if silent is None:
self.fcapp.inform[str, bool].emit(_("Grid disabled."), False)
# HACK: enabling/disabling the cursor seams to somehow update the shapes on screen
# - perhaps is a bug in VisPy implementation
if self.fcapp.grid_status():
self.fcapp.app_cursor.enabled = False
self.fcapp.app_cursor.enabled = True
else:
self.fcapp.app_cursor.enabled = True
self.fcapp.app_cursor.enabled = False
def draw_workspace(self, workspace_size):
"""
Draw a rectangular shape on canvas to specify our valid workspace.
:param workspace_size: the workspace size; tuple
:return:
"""
try:
if self.fcapp.app_units.upper() == 'MM':
dims = self.pagesize_dict[workspace_size]
else:
dims = (self.pagesize_dict[workspace_size][0]/25.4, self.pagesize_dict[workspace_size][1]/25.4)
except Exception as e:
self.app.log.error("PlotCanvas.draw_workspace() --> %s" % str(e))
return
if self.fcapp.options['global_workspace_orientation'] == 'l':
dims = (dims[1], dims[0])
a = np.array([(0, 0), (dims[0], 0), (dims[0], dims[1]), (0, dims[1])])
# if not self.workspace_line:
# self.workspace_line = Line(pos=np.array((a[0], a[1], a[2], a[3], a[0])), color=(0.70, 0.3, 0.3, 0.7),
# antialias=True, method='agg', parent=self.view.scene)
# else:
# self.workspace_line.parent = self.view.scene
self.workspace_line = Line(pos=np.array((a[0], a[1], a[2], a[3], a[0])), color=(0.70, 0.3, 0.3, 0.7),
antialias=True, method='agg', parent=self.view.scene)
self.fcapp.ui.wplace_label.set_value(workspace_size[:3])
self.fcapp.ui.wplace_label.setToolTip(workspace_size)
self.fcapp.ui.wplace_label.setStyleSheet("""
QLabel
{
color: black;
background-color: olivedrab;
}
""")
def delete_workspace(self):
try:
self.workspace_line.parent = None
except Exception:
pass
self.fcapp.ui.wplace_label.setStyleSheet("")
# redraw the workspace lines on the plot by re adding them to the parent view.scene
def restore_workspace(self):
try:
self.workspace_line.parent = self.view.scene
except Exception:
pass
def graph_event_connect(self, event_name, callback):
return getattr(self.events, event_name).connect(callback)
def graph_event_disconnect(self, event_name, callback=None):
if callback is None:
getattr(self.events, event_name).disconnect()
else:
getattr(self.events, event_name).disconnect(callback)
def zoom(self, factor, center=None):
"""
Zooms the plot by factor around a given
center point. Takes care of re-drawing.
:param factor: Number by which to scale the plot.
:type factor: float
:param center: Coordinates [x, y] of the point around which to scale the plot.
:type center: list
:return: None
"""
self.view.camera.zoom(factor, center)
def new_shape_group(self, shape_collection=None):
if shape_collection:
return ShapeGroup(shape_collection)
return ShapeGroup(self.shape_collection)
def new_shape_collection(self, **kwargs):
# sc = ShapeCollection(parent=self.view.scene, pool=self.app.pool, **kwargs)
# self.shape_collections.append(sc)
# return sc
return ShapeCollection(parent=self.view.scene, pool=self.fcapp.pool, **kwargs)
def new_cursor(self, big=None):
"""
Will create a mouse cursor pointer on canvas
:param big: if True will create a mouse cursor made out of infinite lines
:return: the mouse cursor object
"""
if big is True:
self.big_cursor = True
self.c = CursorBig(app=self.fcapp)
# in case there are multiple new_cursor calls, best to disconnect first the signals
try:
self.c.mouse_state_updated.disconnect(self.on_mouse_state)
except (TypeError, AttributeError):
pass
try:
self.c.mouse_position_updated.disconnect(self.on_mouse_position)
except (TypeError, AttributeError):
pass
self.c.mouse_state_updated.connect(self.on_mouse_state)
self.c.mouse_position_updated.connect(self.on_mouse_position)
else:
self.big_cursor = False
self.c = Cursor(pos=np.empty((0, 2)), parent=self.view.scene)
self.c.antialias = 0
return self.c
@property
def cursor_color(self):
return self._cursor_color
@cursor_color.setter
def cursor_color(self, color):
self._cursor_color = Color(color).rgba
if self.big_cursor is True:
self.cursor_h_line.set_data(color=self._cursor_color)
self.cursor_v_line.set_data(color=self._cursor_color)
else:
self.fcapp.cursor_color_3D = self._cursor_color
def on_mouse_state(self, state):
if state:
self.cursor_h_line.parent = self.view.scene
self.cursor_v_line.parent = self.view.scene
else:
self.cursor_h_line.parent = None
self.cursor_v_line.parent = None
def on_mouse_position(self, pos):
if self.fcapp.options['global_cursor_color_enabled']:
# color = Color(self.fcapp.options['global_cursor_color']).rgba
color = self.cursor_color
else:
color = self.line_color
self.cursor_h_line.set_data(pos=pos[1], color=color)
self.cursor_v_line.set_data(pos=pos[0], color=color)
self.view.scene.update()
def on_mouse_scroll(self, event):
# key modifiers
modifiers = event.modifiers
pan_delta_x = self.fcapp.options["global_gridx"]
pan_delta_y = self.fcapp.options["global_gridy"]
curr_pos = event.pos
# Controlled pan by mouse wheel
if 'Shift' in modifiers:
p1 = np.array(curr_pos)[:2]
if event.delta[1] > 0:
curr_pos[0] -= pan_delta_x
else:
curr_pos[0] += pan_delta_x
p2 = np.array(curr_pos)[:2]
self.view.camera.pan(p2 - p1)
elif 'Control' in modifiers:
p1 = np.array(curr_pos)[:2]
if event.delta[1] > 0:
curr_pos[1] += pan_delta_y
else:
curr_pos[1] -= pan_delta_y
p2 = np.array(curr_pos)[:2]
self.view.camera.pan(p2 - p1)
if self.fcapp.grid_status():
pos_canvas = self.translate_coords(curr_pos)
pos = self.fcapp.geo_editor.snap(pos_canvas[0], pos_canvas[1])
# Update cursor
self.fcapp.app_cursor.set_data(np.asarray([(pos[0], pos[1])]),
symbol='++', edge_color=self.cursor_color,
edge_width=self.fcapp.options["global_cursor_width"],
size=self.fcapp.options["global_cursor_size"])
def new_text_group(self, collection=None):
if collection:
return TextGroup(collection)
else:
return TextGroup(self.text_collection)
def new_text_collection(self, **kwargs):
return TextCollection(parent=self.view.scene, **kwargs)
def fit_view(self, rect=None):
# Lock updates in other threads
self.shape_collection.lock_updates()
if not rect:
rect = Rect(-1, -1, 20, 20)
try:
rect.left, rect.right = self.shape_collection.bounds(axis=0)
rect.bottom, rect.top = self.shape_collection.bounds(axis=1)
except TypeError:
pass
# adjust the view camera to be slightly bigger than the bounds so the shape collection can be seen clearly
# otherwise the shape collection boundary will have no border
dx = rect.right - rect.left
dy = rect.top - rect.bottom
x_factor = dx * 0.02
y_factor = dy * 0.02
rect.left -= x_factor
rect.bottom -= y_factor
rect.right += x_factor
rect.top += y_factor
# rect.left *= 0.96
# rect.bottom *= 0.96
# rect.right *= 1.04
# rect.top *= 1.04
# units = self.fcapp.app_units.upper()
# if units == 'MM':
# compensation = 0.5
# else:
# compensation = 0.5 / 25.4
# rect.left -= compensation
# rect.bottom -= compensation
# rect.right += compensation
# rect.top += compensation
self.view.camera.rect = rect
self.shape_collection.unlock_updates()
def fit_center(self, loc, rect=None):
# Lock updates in other threads
self.shape_collection.lock_updates()
if not rect:
try:
rect = Rect(loc[0]-20, loc[1]-20, 40, 40)
except TypeError:
pass
self.view.camera.rect = rect
self.shape_collection.unlock_updates()
def clear(self):
pass
def redraw(self):
self.shape_collection.redraw([])
self.text_collection.redraw()
def on_pool_recreated(self, pool):
self.shape_collection.pool = pool
class CursorBig(QtCore.QObject):
"""
This is a fake cursor to ensure compatibility with the OpenGL engine (VisPy).
This way I don't have to chane (disable) things related to the cursor all over when
using the low performance Matplotlib 2D graphic engine.
"""
mouse_state_updated = QtCore.pyqtSignal(bool)
mouse_position_updated = QtCore.pyqtSignal(list)
def __init__(self, app):
super().__init__()
self.app = app
self._enabled = None
@property
def enabled(self):
return True if self._enabled else False
@enabled.setter
def enabled(self, value):
self._enabled = value
self.mouse_state_updated.emit(value)
def set_data(self, pos, **kwargs):
"""Internal event handler to draw the cursor when the mouse moves."""
# if 'edge_color' in kwargs:
# color = kwargs['edge_color']
# else:
# if self.app.options['global_theme'] == 'white':
# color = '#000000FF'
# else:
# color = '#FFFFFFFF'
position = [pos[0][0], pos[0][1]]
self.mouse_position_updated.emit(position)