def set_label_text(self, value): if self.label_item: old = self.get_label_text() if old != value: self.poke('label_data') self.label_data['text'] = value self.label_item.update_text(value) else: self.label_item = GroupLabel(value, parent=self) self.poke('label_data') self.label_data['text'] = value if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position()
class Group(SavedObject, QtWidgets.QGraphicsObject): __qt_type_id__ = next_available_type_id() permanent_ui = False unique = False can_fade = False scene_item = True is_widget = False def __init__(self, selection=None, persistent=True): SavedObject.__init__(self) QtWidgets.QGraphicsObject.__init__(self) # -- Fake as UIItem to make selection groups part of UI: self.ui_key = next_available_ui_key() self.ui_type = self.__class__.__name__ self.ui_manager = ctrl.ui self.role = None self.host = None self.watchlist = [] self.is_fading_in = False self.is_fading_out = False # -- end faking as UIItem self.selection = [] self.selection_with_children = [] self.persistent = persistent self._skip_this = not persistent self._selected = False self.points = [] self.center_point = None self.outline = False self.fill = True self.color_key = '' self.color = None self.color_tr_tr = None self.purpose = None self.path = None self.label_item = None self.label_data = {} self.include_children = False self.allow_overlap = True self._br = None #self.setFlag(QtWidgets.QGraphicsObject.ItemIsMovable) self.setFlag(QtWidgets.QGraphicsObject.ItemIsSelectable) if selection: self.update_selection(selection) self.update_shape() self.update_colors() def __contains__(self, item): return item in self.selection_with_children def type(self): """ Qt's type identifier, custom QGraphicsItems should have different type ids if events need to differentiate between them. These are set when the program starts. :return: """ return self.__qt_type_id__ def after_init(self): self.update_selection(self.selection) self.update_shape() self.update_colors() def after_model_update(self, changed_fields, transition_type): if changed_fields: self.update_selection(self.selection) self.update_shape() self.update_colors() def copy_from(self, source): """ Helper method to easily make a similar selection with different identity :param source: :return: """ self.selection = source.selection self.selection_with_children = source.selection_with_children self.points = list(source.points) self.center_point = source.center_point self.outline = source.outline self.fill = source.fill self.color_key = source.color_key self.color = source.color self.color_tr_tr = source.color_tr_tr self.path = source.path text = source.get_label_text() if text: self.set_label_text(text) self.include_children = source.include_children self.allow_overlap = source.allow_overlap def get_label_text(self): """ Label text is actually stored in model.label_data, but this is a shortcut for it. :return: """ return self.label_data.get('text', '') def set_label_text(self, value): if self.label_item: old = self.get_label_text() if old != value: self.poke('label_data') self.label_data['text'] = value self.label_item.update_text(value) else: self.label_item = GroupLabel(value, parent=self) self.poke('label_data') self.label_data['text'] = value if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() def if_changed_color_id(self, value): """ Set group color, uses palette id strings as values. :param value: string """ if self.label_item: self.label_item.setDefaultTextColor(ctrl.cm.get(value)) def remove_node(self, node): """ Manual removal of single node, should be called e.g. when node is deleted. :param node: :return: """ if node in self.selection: self.poke('selection') self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def remove_nodes(self, nodes): """ Remove multiple nodes, just to avoid repeated calls to expensive updates :param nodes: :return: """ self.poke('selection') for node in nodes: if node in self.selection: self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def clear(self, remove=True): self.selection = set() self.selection_with_children = set() self.update_shape() if remove: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def add_node(self, node): """ Manual addition of single node :param node: :return: """ if node not in self.selection: self.poke('selection') self.selection.append(node) self.update_selection(self.selection) self.update_shape() def update_selection(self, selection): swc = [] other_selections = set() if not self.allow_overlap: for group in ctrl.forest.groups.values(): other_selections = other_selections | set(group.selection_with_children) def recursive_add_children(i): if isinstance(i, Node) and i not in swc and \ (i in selection or i not in other_selections): swc.append(i) for child in i.get_children(similar=False, visible=False): recursive_add_children(child) if selection: self.selection = [item for item in selection if isinstance(item, Node) and item.can_be_in_groups] if self.include_children: for item in self.selection: recursive_add_children(item) self.selection_with_children = swc else: self.selection_with_children = self.selection else: self.selection = [] self.selection_with_children = [] def update_shape(self): def embellished_corners(item): x1, y1, x2, y2 = item.sceneBoundingRect().getCoords() corners = [(x1 - 5, y1 - 5), (x2 + 5, y1 - 5), (x2 + 5, y2 + 5), (x1 - 5, y2 + 5)] return x1, y1, x2, y2, corners sel = [x for x in self.selection_with_children if x.isVisible()] if len(sel) == 0: self._br = QtCore.QRectF() self.path = None self.center_point = 0, 0 return elif len(sel) == 1: x1, y1, x2, y2, route = embellished_corners(sel[0]) self._br = QtCore.QRectF(x1 - 5, y1 - 5, x2 - x1 + 10, y2 - y1 + 10) self.path = QtGui.QPainterPath(QtCore.QPointF(route[0][0], route[0][1])) for x, y in route[1:]: self.path.lineTo(x, y) self.path.closeSubpath() center = self._br.center() self.center_point = center.x(), center.y() cx, cy = self.center_point else: corners = [] c = 0 x_sum = 0 y_sum = 0 min_x = 50000 max_x = -50000 min_y = 50000 max_y = -50000 for item in sel: c += 2 x1, y1, x2, y2, icorners = embellished_corners(item) x_sum += x1 x_sum += x2 y_sum += y1 y_sum += y2 if x1 < min_x: min_x = x1 if x2 > max_x: max_x = x2 if y1 < min_y: min_y = y1 if y2 > max_y: max_y = y2 corners += icorners self.prepareGeometryChange() self._br = QtCore.QRectF(min_x, min_y, max_x - min_x, max_y - min_y) cx = (min_x + max_x) / 2 cy = (min_y + max_y) / 2 self.center_point = cx, cy r = max(max_x - min_x, max_y - min_y) * 1.1 dots = 32 step = 2 * math.pi / dots deg = 0 route = [] for n in range(0, dots): cpx = math.cos(deg)*r + cx cpy = math.sin(deg)*r + cy deg += step closest = None closest_d = 2000000 for px, py in corners: d = (px - cpx) ** 2 + (py - cpy) ** 2 if d < closest_d: closest = px, py closest_d = d if closest: if route: last = route[-1] if last == closest: continue route.append(closest) if self.label_item: if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() curved_path = Group.interpolate_point_with_bezier_curves(route) sx, sy = route[0] self.path = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: self.path.cubicTo(fx, fy, sx, sy, ex, ey) # This is costly if True: for item in self.collidingItems(): if isinstance(item, Node) and item.node_type == g.CONSTITUENT_NODE and item not in \ self.selection_with_children: x, y = item.current_scene_position subshape = item.shape().translated(x, y) subshape_points = [] for i in range(0, subshape.elementCount()): element = subshape.elementAt(i) subshape_points.append((element.x, element.y)) curved_path = Group.interpolate_point_with_bezier_curves(subshape_points) sx, sy = subshape_points[0] subshape = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: subshape.cubicTo(fx, fy, sx, sy, ex, ey) self.path = self.path.subtracted(subshape) def shape(self): if self.path: return self.path else: return QtGui.QPainterPath() def update_position(self): self.update_shape() def clockwise_path_points(self, margin=2): """ Return points along the path circling the group. A margin can be provided to make the points be n points away from the path. Points start from the topmost, rightmost point. :param margin: :return: """ if not self.path: return QtCore.QPointF(0, 0) # hope this group will be removed immediately max_x = -30000 min_y = 30000 start_i = 0 ppoints = [] cx, cy = self.center_point better_path = [] # lets have not only corners, but also points along the edges last_element = self.path.elementAt(self.path.elementCount()-1) last_x = last_element.x last_y = last_element.y for i in range(0, self.path.elementCount()): element = self.path.elementAt(i) x = element.x y = element.y better_path.append(((last_x + x) / 2, (last_y + y) / 2)) better_path.append((x, y)) last_x = x last_y = y for i, (x, y) in enumerate(better_path): if margin != 0: dx = x - cx dy = y - cy d = math.hypot(dx, dy) if d == 0: change = 0 else: change = (d + margin) / d # should return values like 1.08 x = cx + (dx * change) y = cy + (dy * change) ppoints.append((x, y)) if y < min_y or (y == min_y and x > max_x): min_y = y max_x = x start_i = i return ppoints[start_i:] + ppoints[:start_i] def boundingRect(self): if not self._br: self.update_shape() return self._br def get_color_id(self): return self.color_key def update_colors(self, color_key=''): if not self.color_key: self.color_key = color_key or "accent1" elif color_key: self.color_key = color_key self.color = ctrl.cm.get(self.color_key) self.color_tr_tr = QtGui.QColor(self.color) self.color_tr_tr.setAlphaF(0.2) if self.label_item: self.label_item.update_color() def mousePressEvent(self, event): ctrl.press(self) super().mousePressEvent(event) def mouseReleaseEvent(self, event): if ctrl.pressed is self: ctrl.release(self) if ctrl.dragged_set: ctrl.graph_scene.kill_dragging() ctrl.ui.update_selections() # drag operation may have changed visible affordances else: # This is regular click on 'pressed' object self.select(event) self.update() return None # this mouseRelease is now consumed super().mouseReleaseEvent(event) def select(self, event=None, multi=False): """ Scene has decided that this node has been clicked :param event: :param multi: assume multiple selection (append, don't replace) """ if not self.persistent: return ctrl.multiselection_start() if (event and event.modifiers() == QtCore.Qt.ShiftModifier) or multi: # multiple selection if ctrl.is_selected(self): ctrl.remove_from_selection(self) else: ctrl.add_to_selection(self) for item in self.selection: ctrl.add_to_selection(item) elif ctrl.is_selected(self): ctrl.deselect_objects() else: ctrl.deselect_objects() ctrl.add_to_selection(self) for item in self.selection: ctrl.add_to_selection(item) ctrl.multiselection_end() def update_selection_status(self, value): """ :param value: :return: """ self._selected = value def paint(self, painter, style, QWidget_widget=None): if self.selection and self.path: if self.fill: painter.fillPath(self.path, self.color_tr_tr) if self._selected: painter.setPen(ctrl.cm.selection()) painter.drawPath(self.path) elif self.outline: painter.setPen(self.color) painter.drawPath(self.path) @staticmethod def interpolate_point_with_bezier_curves(points): """ Curved path algorithm based on example by Raul Otaño Hurtado, from http://www.codeproject.com/Articles/769055/Interpolate-D-points-usign-Bezier-curves-in-WPF :param points: :return: """ if len(points) < 3: return None res = [] # if is close curve then add the first point at the end if points[-1] != points[0]: points.append(points[0]) for i, (x1, y1) in enumerate(points[:-1]): if i == 0: x0, y0 = points[-2] else: x0, y0 = points[i - 1] x2, y2 = points[i + 1] if i == len(points) - 2: x3, y3 = points[1] else: x3, y3 = points[i + 2] xc1 = (x0 + x1) / 2.0 yc1 = (y0 + y1) / 2.0 xc2 = (x1 + x2) / 2.0 yc2 = (y1 + y2) / 2.0 xc3 = (x2 + x3) / 2.0 yc3 = (y2 + y3) / 2.0 len1 = math.hypot(x1 - x0, y1 - y0) len2 = math.hypot(x2 - x1, y2 - y1) len3 = math.hypot(x3 - x2, y3 - y2) k1 = len1 / (len1 + len2) k2 = len2 / (len2 + len3) xm1 = xc1 + (xc2 - xc1) * k1 ym1 = yc1 + (yc2 - yc1) * k1 xm2 = xc2 + (xc3 - xc2) * k2 ym2 = yc2 + (yc3 - yc2) * k2 smooth = 0.8 ctrl1_x = xm1 + (xc2 - xm1) * smooth + x1 - xm1 ctrl1_y = ym1 + (yc2 - ym1) * smooth + y1 - ym1 ctrl2_x = xm2 + (xc2 - xm2) * smooth + x2 - xm2 ctrl2_y = ym2 + (yc2 - ym2) * smooth + y2 - ym2 res.append((ctrl1_x, ctrl1_y, ctrl2_x, ctrl2_y, x2, y2)) return res selection = SavedField("selection") color_key = SavedField("color_key") label_data = SavedField("label_data") purpose = SavedField("purpose") include_children = SavedField("include_children") allow_overlap = SavedField("allow_overlap") fill = SavedField("fill") outline = SavedField("outline") persistent = SavedField("persistent") forest = SavedField("forest")
class Group(SavedObject, QtWidgets.QGraphicsObject): __qt_type_id__ = next_available_type_id() permanent_ui = False unique = False can_fade = False scene_item = True is_widget = False def __init__(self, selection=None, persistent=True): SavedObject.__init__(self) QtWidgets.QGraphicsObject.__init__(self) # -- Fake as UIItem to make selection groups part of UI: self.ui_key = next_available_ui_key() self.ui_type = self.__class__.__name__ self.ui_manager = ctrl.ui self.role = None self.host = None self.watchlist = [] self.is_fading_in = False self.is_fading_out = False # -- end faking as UIItem self.selection = [] self.selection_with_children = [] self.persistent = persistent self._skip_this = not persistent self._selected = False self.points = [] self.center_point = None self.outline = False self.fill = True self.color_key = '' self.color = None self.color_tr_tr = None self.purpose = None self.path = None self.label_item = None self.label_data = {} self.include_children = False self.allow_overlap = True self._br = None #self.setFlag(QtWidgets.QGraphicsObject.ItemIsMovable) self.setFlag(QtWidgets.QGraphicsObject.ItemIsSelectable) if selection: self.update_selection(selection) self.update_shape() self.update_colors() def __contains__(self, item): return item in self.selection_with_children def type(self): """ Qt's type identifier, custom QGraphicsItems should have different type ids if events need to differentiate between them. These are set when the program starts. :return: """ return self.__qt_type_id__ def after_init(self): self.update_selection(self.selection) self.update_shape() self.update_colors() def after_model_update(self, updated_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ if transition_type == g.CREATED: ctrl.forest.store(self) ctrl.forest.add_to_scene(self) elif transition_type == g.DELETED: ctrl.forest.remove_from_scene(self, fade_out=False) return if updated_fields: self.update_selection(self.selection) self.update_shape() self.update_colors() def copy_from(self, source): """ Helper method to easily make a similar selection with different identity :param source: :return: """ self.selection = source.selection self.selection_with_children = source.selection_with_children self.points = list(source.points) self.center_point = source.center_point self.outline = source.outline self.fill = source.fill self.color_key = source.color_key self.color = source.color self.color_tr_tr = source.color_tr_tr self.path = source.path text = source.get_label_text() if text: self.set_label_text(text) self.include_children = source.include_children self.allow_overlap = source.allow_overlap def get_label_text(self): """ Label text is actually stored in model.label_data, but this is a shortcut for it. :return: """ return self.label_data.get('text', '') def set_label_text(self, value): if self.label_item: old = self.get_label_text() if old != value: self.poke('label_data') self.label_data['text'] = value self.label_item.update_text(value) else: self.label_item = GroupLabel(value, parent=self) self.poke('label_data') self.label_data['text'] = value if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() def if_changed_color_id(self, value): """ Set group color, uses palette id strings as values. :param value: string """ if self.label_item: self.label_item.setDefaultTextColor(ctrl.cm.get(value)) def remove_node(self, node): """ Manual removal of single node, should be called e.g. when node is deleted. :param node: :return: """ if node in self.selection: self.poke('selection') self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def remove_nodes(self, nodes): """ Remove multiple nodes, just to avoid repeated calls to expensive updates :param nodes: :return: """ self.poke('selection') for node in nodes: if node in self.selection: self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def clear(self, remove=True): self.selection = set() self.selection_with_children = set() self.update_shape() if remove: if self.persistent: ctrl.free_drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def add_node(self, node): """ Manual addition of single node :param node: :return: """ if node not in self.selection: self.poke('selection') self.selection.append(node) self.update_selection(self.selection) self.update_shape() def update_selection(self, selection): swc = [] other_selections = set() if not self.allow_overlap: for group in ctrl.forest.groups.values(): other_selections = other_selections | set( group.selection_with_children) def recursive_add_children(i): if isinstance(i, Node) and i not in swc and \ (i in selection or i not in other_selections): swc.append(i) for child in i.get_children(similar=False, visible=False): recursive_add_children(child) if selection: self.selection = [ item for item in selection if isinstance(item, Node) and item.can_be_in_groups ] if self.include_children: for item in self.selection: recursive_add_children(item) self.selection_with_children = swc else: self.selection_with_children = self.selection else: self.selection = [] self.selection_with_children = [] def update_shape(self): def embellished_corners(item): x1, y1, x2, y2 = item.sceneBoundingRect().getCoords() corners = [(x1 - 5, y1 - 5), (x2 + 5, y1 - 5), (x2 + 5, y2 + 5), (x1 - 5, y2 + 5)] return x1, y1, x2, y2, corners sel = [x for x in self.selection_with_children if x.isVisible()] if len(sel) == 0: self._br = QtCore.QRectF() self.path = None self.center_point = 0, 0 return elif len(sel) == 1: x1, y1, x2, y2, route = embellished_corners(sel[0]) self._br = QtCore.QRectF(x1 - 5, y1 - 5, x2 - x1 + 10, y2 - y1 + 10) self.path = QtGui.QPainterPath( QtCore.QPointF(route[0][0], route[0][1])) for x, y in route[1:]: self.path.lineTo(x, y) self.path.closeSubpath() center = self._br.center() self.center_point = center.x(), center.y() cx, cy = self.center_point else: corners = [] c = 0 x_sum = 0 y_sum = 0 min_x = 50000 max_x = -50000 min_y = 50000 max_y = -50000 for item in sel: c += 2 x1, y1, x2, y2, icorners = embellished_corners(item) x_sum += x1 x_sum += x2 y_sum += y1 y_sum += y2 if x1 < min_x: min_x = x1 if x2 > max_x: max_x = x2 if y1 < min_y: min_y = y1 if y2 > max_y: max_y = y2 corners += icorners self.prepareGeometryChange() self._br = QtCore.QRectF(min_x, min_y, max_x - min_x, max_y - min_y) cx = (min_x + max_x) / 2 cy = (min_y + max_y) / 2 self.center_point = cx, cy r = max(max_x - min_x, max_y - min_y) * 1.1 dots = 32 step = 2 * math.pi / dots deg = 0 route = [] for n in range(0, dots): cpx = math.cos(deg) * r + cx cpy = math.sin(deg) * r + cy deg += step closest = None closest_d = 2000000 for px, py in corners: d = (px - cpx)**2 + (py - cpy)**2 if d < closest_d: closest = px, py closest_d = d if closest: if route: last = route[-1] if last == closest: continue route.append(closest) if self.label_item: if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() curved_path = Group.interpolate_point_with_bezier_curves(route) sx, sy = route[0] self.path = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: self.path.cubicTo(fx, fy, sx, sy, ex, ey) # This is costly if True: for item in self.collidingItems(): if isinstance(item, Node) and item.node_type == g.CONSTITUENT_NODE and item not in \ self.selection_with_children: x, y = item.current_scene_position subshape = item.shape().translated(x, y) subshape_points = [] for i in range(0, subshape.elementCount()): element = subshape.elementAt(i) subshape_points.append((element.x, element.y)) curved_path = Group.interpolate_point_with_bezier_curves( subshape_points) sx, sy = subshape_points[0] subshape = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: subshape.cubicTo(fx, fy, sx, sy, ex, ey) self.path = self.path.subtracted(subshape) def shape(self): if self.path: return self.path else: return QtGui.QPainterPath() def update_position(self): self.update_shape() def clockwise_path_points(self, margin=2): """ Return points along the path circling the group. A margin can be provided to make the points be n points away from the path. Points start from the topmost, rightmost point. :param margin: :return: """ if not self.path: return QtCore.QPointF( 0, 0) # hope this group will be removed immediately max_x = -30000 min_y = 30000 start_i = 0 ppoints = [] cx, cy = self.center_point better_path = [ ] # lets have not only corners, but also points along the edges last_element = self.path.elementAt(self.path.elementCount() - 1) last_x = last_element.x last_y = last_element.y for i in range(0, self.path.elementCount()): element = self.path.elementAt(i) x = element.x y = element.y better_path.append(((last_x + x) / 2, (last_y + y) / 2)) better_path.append((x, y)) last_x = x last_y = y for i, (x, y) in enumerate(better_path): if margin != 0: dx = x - cx dy = y - cy d = math.hypot(dx, dy) if d == 0: change = 0 else: change = (d + margin) / d # should return values like 1.08 x = cx + (dx * change) y = cy + (dy * change) ppoints.append((x, y)) if y < min_y or (y == min_y and x > max_x): min_y = y max_x = x start_i = i return ppoints[start_i:] + ppoints[:start_i] def boundingRect(self): if not self._br: self.update_shape() return self._br def get_color_id(self): return self.color_key def update_colors(self, color_key=''): if not self.color_key: self.color_key = color_key or "accent1" elif color_key: self.color_key = color_key self.color = ctrl.cm.get(self.color_key) self.color_tr_tr = QtGui.QColor(self.color) self.color_tr_tr.setAlphaF(0.2) if self.label_item: self.label_item.update_color() def mousePressEvent(self, event): ctrl.press(self) super().mousePressEvent(event) def mouseReleaseEvent(self, event): if ctrl.pressed is self: ctrl.release(self) if ctrl.dragged_set: ctrl.graph_scene.kill_dragging() ctrl.ui.update_selections( ) # drag operation may have changed visible affordances else: # This is regular click on 'pressed' object self.select(event) self.update() return None # this mouseRelease is now consumed super().mouseReleaseEvent(event) def select(self, event=None, multi=False): """ Scene has decided that this node has been clicked :param event: :param multi: assume multiple selection (append, don't replace) """ if not self.persistent: return ctrl.multiselection_start() if (event and event.modifiers() == QtCore.Qt.ShiftModifier) or multi: # multiple selection if ctrl.is_selected(self): ctrl.remove_from_selection(self) else: ctrl.add_to_selection(self) for item in self.selection: ctrl.add_to_selection(item) elif ctrl.is_selected(self): ctrl.deselect_objects() else: ctrl.deselect_objects() ctrl.add_to_selection(self) for item in self.selection: ctrl.add_to_selection(item) ctrl.multiselection_end() def update_selection_status(self, value): """ :param value: :return: """ self._selected = value def paint(self, painter, style, QWidget_widget=None): if self.selection and self.path: if self.fill: painter.fillPath(self.path, self.color_tr_tr) if self._selected: painter.setPen(ctrl.cm.selection()) painter.drawPath(self.path) elif self.outline: painter.setPen(self.color) painter.drawPath(self.path) @staticmethod def interpolate_point_with_bezier_curves(points): """ Curved path algorithm based on example by Raul Otaño Hurtado, from http://www.codeproject.com/Articles/769055/Interpolate-D-points-usign-Bezier-curves-in-WPF :param points: :return: """ if len(points) < 3: return None res = [] # if is close curve then add the first point at the end if points[-1] != points[0]: points.append(points[0]) for i, (x1, y1) in enumerate(points[:-1]): if i == 0: x0, y0 = points[-2] else: x0, y0 = points[i - 1] x2, y2 = points[i + 1] if i == len(points) - 2: x3, y3 = points[1] else: x3, y3 = points[i + 2] xc1 = (x0 + x1) / 2.0 yc1 = (y0 + y1) / 2.0 xc2 = (x1 + x2) / 2.0 yc2 = (y1 + y2) / 2.0 xc3 = (x2 + x3) / 2.0 yc3 = (y2 + y3) / 2.0 len1 = math.hypot(x1 - x0, y1 - y0) len2 = math.hypot(x2 - x1, y2 - y1) len3 = math.hypot(x3 - x2, y3 - y2) k1 = len1 / (len1 + len2) k2 = len2 / (len2 + len3) xm1 = xc1 + (xc2 - xc1) * k1 ym1 = yc1 + (yc2 - yc1) * k1 xm2 = xc2 + (xc3 - xc2) * k2 ym2 = yc2 + (yc3 - yc2) * k2 smooth = 0.8 ctrl1_x = xm1 + (xc2 - xm1) * smooth + x1 - xm1 ctrl1_y = ym1 + (yc2 - ym1) * smooth + y1 - ym1 ctrl2_x = xm2 + (xc2 - xm2) * smooth + x2 - xm2 ctrl2_y = ym2 + (yc2 - ym2) * smooth + y2 - ym2 res.append((ctrl1_x, ctrl1_y, ctrl2_x, ctrl2_y, x2, y2)) return res selection = SavedField("selection") color_key = SavedField("color_key") label_data = SavedField("label_data") purpose = SavedField("purpose") include_children = SavedField("include_children") allow_overlap = SavedField("allow_overlap") fill = SavedField("fill") outline = SavedField("outline") persistent = SavedField("persistent") forest = SavedField("forest")
class Group(SavedObject, QtWidgets.QGraphicsObject): __qt_type_id__ = next_available_type_id() permanent_ui = False unique = False can_fade = False scene_item = True is_widget = False def __init__(self, selection=None, persistent=True, color_key='accent1', forest=None): SavedObject.__init__(self) QtWidgets.QGraphicsObject.__init__(self) # -- Fake as UIItem to make selection groups part of UI: self.ui_key = next_available_ui_key() self.ui_type = self.__class__.__name__ self.ui_manager = ctrl.ui self.role = None self.host = None self.forest = forest self.is_fading_in = False self.is_fading_out = False # -- end faking as UIItem self.selection = [] self.selection_with_children = [] self.persistent = persistent self._skip_this = not persistent self._selected = False self.points = [] self.center_point = None self.outline = False self.fill = True self.color_key = color_key self.color = None self.color_tr_tr = None self.purpose = None self.path = None self.label_item = None self.label_data = {} self.buttons = [] self._br = None # self.setFlag(QtWidgets.QGraphicsObject.ItemIsMovable) self.setFlag(QtWidgets.QGraphicsObject.ItemIsSelectable) self._should_update = False if selection: self.update_selection(selection) self.update_shape() self.update_colors() def __contains__(self, item): return item in self.selection_with_children def type(self): """ Qt's type identifier, custom QGraphicsItems should have different type ids if events need to differentiate between them. These are set when the program starts. :return: """ return self.__qt_type_id__ def after_init(self): self.update_selection(self.selection) self.update_shape() self.update_colors() def after_model_update(self, updated_fields, transition_type): """ Compute derived effects of updated values in sensible order. :param updated_fields: field keys of updates :param transition_type: 0:edit, 1:CREATED, -1:DELETED :return: None """ if transition_type == g.CREATED: self.forest.store(self) self.forest.add_to_scene(self) elif transition_type == g.DELETED: self.forest.remove_from_scene(self, fade_out=False) return if updated_fields: self.update_selection(self.selection) self.update_shape() self.update_colors() def copy_from(self, source): """ Helper method to easily make a similar selection with different identity :param source: :return: """ self.selection = source.selection self.selection_with_children = source.selection_with_children self.points = list(source.points) self.center_point = source.center_point self.outline = source.outline self.fill = source.fill self.color_key = source.color_key self.color = source.color self.color_tr_tr = source.color_tr_tr self.path = source.path text = source.get_label_text() if text: self.set_label_text(text) def get_label_text(self): """ Label text is actually stored in model.label_data, but this is a shortcut for it. :return: """ return self.label_data.get('text', '') def set_label_text(self, value): if self.label_item: old = self.get_label_text() if old != value: self.poke('label_data') self.label_data['text'] = value self.label_item.update_text(value) else: self.label_item = GroupLabel(value, parent=self) self.poke('label_data') self.label_data['text'] = value if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() def remove_node(self, node, delete_if_empty=True): """ Manual removal of single node, should be called e.g. when node is deleted. :param node: :return: """ if node in self.selection: self.poke('selection') self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent and delete_if_empty: ctrl.drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def remove_nodes(self, nodes): """ Remove multiple nodes, just to avoid repeated calls to expensive updates :param nodes: :return: """ self.poke('selection') for node in nodes: if node in self.selection: self.selection.remove(node) if node in self.selection_with_children: self.selection_with_children.remove(node) if self.selection: self.update_shape() else: if self.persistent: ctrl.drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def clear(self, remove=True): self.selection = set() self.selection_with_children = set() self.update_shape() if remove: if self.persistent: ctrl.drawing.remove_group(self) else: ctrl.ui.remove_ui_for(self) def add_node(self, node): """ Manual addition of single node :param node: :return: """ if node not in self.selection: self.poke('selection') self.selection.append(node) self.update_selection(self.selection) self.update_shape() def update_selection(self, selection): if selection: self.selection = [item for item in selection if isinstance(item, Node) and item.can_be_in_groups] self.selection_with_children = self.selection else: self.selection = [] self.selection_with_children = [] def please_update(self): self._should_update = True def update_if_needed(self): if self._should_update: self._should_update = False self.update_shape() def update_shape(self): def embellished_corners(item): x1, y1, x2, y2 = item.sceneBoundingRect().getCoords() corners = [(x1 - 5, y1 - 5), (x2 + 5, y1 - 5), (x2 + 5, y2 + 5), (x1 - 5, y2 + 5)] return x1, y1, x2, y2, corners self.prepareGeometryChange() sel = [x for x in self.selection_with_children if x.isVisible()] if len(sel) == 0: self._br = QtCore.QRectF() self.path = QtGui.QPainterPath() self.center_point = 0, 0 return elif len(sel) == 1: x1, y1, x2, y2, route = embellished_corners(sel[0]) self.path = QtGui.QPainterPath(QtCore.QPointF(route[0][0], route[0][1])) for x, y in route[1:]: self.path.lineTo(x, y) self.path.closeSubpath() else: corners = [] c = 0 min_x = 50000 max_x = -50000 min_y = 50000 max_y = -50000 for item in sel: c += 2 x1, y1, x2, y2, icorners = embellished_corners(item) if x1 < min_x: min_x = x1 if x2 > max_x: max_x = x2 if y1 < min_y: min_y = y1 if y2 > max_y: max_y = y2 corners += icorners self.prepareGeometryChange() cx = (min_x + max_x) / 2 cy = (min_y + max_y) / 2 r = max(max_x - min_x, max_y - min_y) * 1.1 dots = 32 step = 2 * math.pi / dots deg = 0 route = [] for n in range(0, dots): cpx = math.cos(deg) * r + cx cpy = math.sin(deg) * r + cy deg += step closest = None closest_d = 2000000 for px, py in corners: d = (px - cpx) ** 2 + (py - cpy) ** 2 if d < closest_d: closest = px, py closest_d = d if closest: if route: last = route[-1] if last == closest: continue route.append(closest) if self.label_item: if self.label_item.automatic_position: self.label_item.position_at_bottom() else: self.label_item.update_position() curved_path = Group.interpolate_point_with_bezier_curves(route) sx, sy = route[0] self.path = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) xs = [] ys = [] for fx, fy, sx, sy, ex, ey in curved_path: xs += [fx, sx, ex] ys += [fy, sy, ey] self.path.cubicTo(fx, fy, sx, sy, ex, ey) min_x = min(xs) min_y = min(ys) self._br = QtCore.QRectF(min_x, min_y, max(xs) - min_x, max(ys) - min_y) center = self._br.center() self.center_point = center.x(), center.y() # This is costly if True: for item in self.collidingItems(): if (isinstance(item, Node) and item.node_type == g.CONSTITUENT_NODE and item not in self.selection_with_children): x, y = item.current_scene_position subshape = item.shape().translated(x, y) subshape_points = [] for i in range(0, subshape.elementCount()): element = subshape.elementAt(i) subshape_points.append((element.x, element.y)) curved_path = Group.interpolate_point_with_bezier_curves(subshape_points) sx, sy = subshape_points[0] subshape = QtGui.QPainterPath(QtCore.QPointF(sx, sy)) for fx, fy, sx, sy, ex, ey in curved_path: subshape.cubicTo(fx, fy, sx, sy, ex, ey) self.path = self.path.subtracted(subshape) def shape(self): if self.path is not None: return self.path else: return QtGui.QPainterPath() def update_position(self): self.update_shape() def boundingRect(self): if self._br is None: self.update_shape() return self._br def get_color_key(self): return self.color_key def set_color_key(self, color_key): self.color_key = color_key or "accent1" self.update_colors() def update_colors(self): self.color_key = self.color_key or "accent1" self.color = ctrl.cm.get(self.color_key) self.color_tr_tr = QtGui.QColor(self.color) self.color_tr_tr.setAlphaF(0.2) if self.label_item: self.label_item.update_color() def mousePressEvent(self, event): ctrl.press(self) super().mousePressEvent(event) def mouseReleaseEvent(self, event): if ctrl.pressed is self: ctrl.release(self) if ctrl.dragged_set: ctrl.graph_scene.kill_dragging() ctrl.ui.update_selections() # drag operation may have changed visible affordances else: # This is regular click on 'pressed' object self.select(event) self.update() return None # this mouseRelease is now consumed super().mouseReleaseEvent(event) def select(self, adding=False, select_area=False): """ Scene has decided that this node has been clicked :param adding: bool, we are adding to selection instead of starting a new selection :param select_area: bool, we are dragging a selection box, method only informs that this node can be included :returns: int or str, uid of node if node is selectable """ self.hovering = False # if we are selecting an area, select actions are not called here, but once for all # objects. In this case return only uid of this object. if select_area: return self.uid #items = [x.uid for x in self.selection] #items.append(self.uid) if adding: if ctrl.is_selected(self): print('selected group (adding=True), calling remove_from_selection for it') ctrl.ui.get_action('remove_from_selection').run_command([self.uid]) else: print('selected group (adding=True), calling add_to_selection for it') ctrl.ui.get_action('add_to_selection').run_command([self.uid]) else: print('selected group, calling select for it') ctrl.ui.get_action('select').run_command([self.uid]) return self.uid def update_selection_status(self, value): """ :param value: :return: """ self._selected = value def paint(self, painter, style, QWidget_widget=None): if self.selection and self.path: if self.fill: painter.fillPath(self.path, self.color_tr_tr) if self._selected: painter.setPen(ctrl.cm.selection()) painter.drawPath(self.path) elif self.outline: painter.setPen(self.color) painter.drawPath(self.path) def position_for_buttons(self): scb = self.sceneBoundingRect() return QtCore.QPointF(scb.center().x(), scb.top() - 8) def add_button(self, button): if button not in self.buttons: self.buttons.append(button) def index_for_button(self, button): return self.buttons.index(button), len(self.buttons) @staticmethod def interpolate_point_with_bezier_curves(points): """ Curved path algorithm based on example by Raul Otaño Hurtado, from http://www.codeproject.com/Articles/769055/Interpolate-D-points-usign-Bezier-curves-in-WPF :param points: :return: """ if len(points) < 3: return None res = [] # if is close curve then add the first point at the end if points[-1] != points[0]: points.append(points[0]) for i, (x1, y1) in enumerate(points[:-1]): if i == 0: x0, y0 = points[-2] else: x0, y0 = points[i - 1] x2, y2 = points[i + 1] if i == len(points) - 2: x3, y3 = points[1] else: x3, y3 = points[i + 2] xc1 = (x0 + x1) / 2.0 yc1 = (y0 + y1) / 2.0 xc2 = (x1 + x2) / 2.0 yc2 = (y1 + y2) / 2.0 xc3 = (x2 + x3) / 2.0 yc3 = (y2 + y3) / 2.0 len1 = math.hypot(x1 - x0, y1 - y0) len2 = math.hypot(x2 - x1, y2 - y1) len3 = math.hypot(x3 - x2, y3 - y2) k1 = len1 / (len1 + len2) k2 = len2 / (len2 + len3) xm1 = xc1 + (xc2 - xc1) * k1 ym1 = yc1 + (yc2 - yc1) * k1 xm2 = xc2 + (xc3 - xc2) * k2 ym2 = yc2 + (yc3 - yc2) * k2 smooth = 0.8 ctrl1_x = xm1 + (xc2 - xm1) * smooth + x1 - xm1 ctrl1_y = ym1 + (yc2 - ym1) * smooth + y1 - ym1 ctrl2_x = xm2 + (xc2 - xm2) * smooth + x2 - xm2 ctrl2_y = ym2 + (yc2 - ym2) * smooth + y2 - ym2 res.append((ctrl1_x, ctrl1_y, ctrl2_x, ctrl2_y, x2, y2)) return res selection = SavedField("selection") color_key = SavedField("color_key") label_data = SavedField("label_data") purpose = SavedField("purpose") fill = SavedField("fill") outline = SavedField("outline") persistent = SavedField("persistent") forest = SavedField("forest")