diff --git a/gui/document.py b/gui/document.py index 971c90887..6377e5799 100644 --- a/gui/document.py +++ b/gui/document.py @@ -1846,30 +1846,31 @@ def rotate_centered_cb(self, action, *test): def symmetry_active_toggled_cb(self, action): """Handle changes to the SymmetryActive toggle""" - already_active = bool(self.model.layer_stack.symmetry_active) + stack = self.model.layer_stack + center = None want_active = bool(action.get_active()) - if want_active and not already_active: + + # When going from an unset state to an active state, the symmetry + # center (model coordinates) is set based on the center of the viewport + if stack.symmetry_unset and want_active: + stack.symmetry_unset = False alloc = self.tdw.get_allocation() - axis_x_pos = self.model.layer_stack.symmetry_x - axis_y_pos = self.model.layer_stack.symmetry_y - if axis_x_pos is None or axis_y_pos is None: - center_disp = alloc.width / 2.0, alloc.height / 2.0 - center_model = self.tdw.display_to_model(*center_disp) - axis_x_pos = center_model[0] - axis_y_pos = center_model[1] - self.model.layer_stack.symmetry_x = axis_x_pos - self.model.layer_stack.symmetry_y = axis_y_pos - if want_active != already_active: - self.model.layer_stack.symmetry_active = want_active - - def _symmetry_state_changed_cb(self, layerstack, active, x, y, - sym_type, rot_sym_lines, sym_angle): + dx, dy = alloc.width / 2.0, alloc.height / 2.0 + center = self.tdw.display_to_model(dx, dy) + + already_active = stack.symmetry_active + if want_active != already_active or center is not None: + stack.set_symmetry_state(want_active, center=center) + + def _symmetry_state_changed_cb( + self, stack, active, center, sym_type, sym_lines, sym_angle): """Update the SymmetryActive toggle on model state changes""" - symm_toggle = self.action_group.get_action("SymmetryActive") - symm_toggle_active = bool(symm_toggle.get_active()) - model_symm_active = bool(active) - if symm_toggle_active != model_symm_active: - symm_toggle.set_active(model_symm_active) + if active is not None: + symm_toggle = self.action_group.get_action("SymmetryActive") + symm_toggle_active = bool(symm_toggle.get_active()) + model_symm_active = bool(active) + if symm_toggle_active != model_symm_active: + symm_toggle.set_active(model_symm_active) ## More viewport manipulation diff --git a/gui/symmetry.py b/gui/symmetry.py index cd14e2cd7..82ea4eec2 100644 --- a/gui/symmetry.py +++ b/gui/symmetry.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of MyPaint. # Copyright (C) 2012-2014 by Andrew Chadwick # @@ -21,8 +22,12 @@ import gui.windowing import gui.tileddrawwidget import lib.alg -import lib.helpers +from lib.helpers import clamp import lib.mypaintlib +from lib.mypaintlib import ( + SymmetryHorizontal, SymmetryVertical, SymmetryVertHorz, + SymmetryRotational, SymmetrySnowflake +) import lib.tiledsurface import gui.drawutils from lib.gettext import C_ @@ -41,11 +46,11 @@ class _EditZone: - UNKNOWN = 0 + NONE = 0 CREATE_AXIS = 1 - MOVE_X_AXIS = 2 - MOVE_Y_AXIS = 3 - DELETE_AXIS = 4 + MOVE_AXIS = 2 + MOVE_CENTER = 3 + DISABLE = 4 class SymmetryEditMode (gui.mode.ScrollableModeMixin, gui.mode.DragMode): @@ -63,15 +68,15 @@ class SymmetryEditMode (gui.mode.ScrollableModeMixin, gui.mode.DragMode): active_cursor = None unmodified_persist = True - permitted_switch_actions = set([ - 'ShowPopupMenu', - 'RotateViewMode', - 'ZoomViewMode', - 'PanViewMode', - ]) + permitted_switch_actions = { + 'ShowPopupMenu', 'RotateViewMode', 'ZoomViewMode', 'PanViewMode', + } _GRAB_SENSITIVITY = 8 # pixels + DISABLE_BUTTON_RADIUS = gui.style.FLOATING_BUTTON_RADIUS + CENTER_BUTTON_RADIUS = gui.style.FLOATING_BUTTON_RADIUS / 2 + # Statusbar stuff _STATUSBAR_CONTEXT = 'symmetry-mode' _STATUSBAR_CREATE_AXIS_MSG = C_( @@ -113,31 +118,38 @@ def __init__(self, **kwds): from gui.application import get_app app = get_app() self.app = app + self.zone = _EditZone.NONE + self.active_axis = 0 + self.button_pos = None + self.center_pos = None + # Whether to render the actual alpha value, or a clamped value. + # The clamped value should be used when moving the cursor across + # the canvas when editing, whereas the real alpha should be used + # when actively editing the alpha value. + self.real_alpha = False + statusbar_cid = app.statusbar.get_context_id(self._STATUSBAR_CONTEXT) self._statusbar_context_id = statusbar_cid - self._drag_start_x = None - self._drag_start_y = None - self._drag_start_model_x = None - self._drag_start_model_y = None - self.zone = _EditZone.UNKNOWN + self._drag_start_pos = None + self._drag_axis_p2 = None self._last_msg_zone = None self._click_info = None - self.button_pos = None self._entered_before = False - self.line_alphafrac = 0.0 + + def _get_cursor(self, name): + return self.app.cursors.get_action_cursor(self.ACTION_NAME, name) def enter(self, doc, **kwds): """Enter the mode""" super(SymmetryEditMode, self).enter(doc, **kwds) # Initialize/fetch cursors - def mkcursor(name): - return doc.app.cursors.get_action_cursor(self.ACTION_NAME, name) + self.cursor_remove = self._get_cursor(gui.cursor.Name.ARROW) + self.cursor_add = self._get_cursor(gui.cursor.Name.ADD) + self.cursor_normal = self._get_cursor(gui.cursor.Name.ARROW) + self.cursor_movable = self._get_cursor(gui.cursor.Name.HAND_OPEN) + self.cursor_moving = self._get_cursor(gui.cursor.Name.HAND_CLOSED) - self._move_cursors = {} - self.cursor_remove = mkcursor(gui.cursor.Name.ARROW) - self.cursor_add = mkcursor(gui.cursor.Name.ADD) - self.cursor_normal = mkcursor(gui.cursor.Name.ARROW) # Turn on the axis, if it happens to be off right now if not self._entered_before: action = self.app.find_action("SymmetryActive") @@ -149,19 +161,19 @@ def _update_statusbar(self): return if self._last_msg_zone == self.zone: return + self._last_msg_zone = self.zone statusbar = self.app.statusbar statusbar_cid = self._statusbar_context_id statusbar.remove_all(statusbar_cid) msgs = { _EditZone.CREATE_AXIS: self._STATUSBAR_CREATE_AXIS_MSG, - _EditZone.MOVE_X_AXIS: self._STATUSBAR_MOVE_AXIS_MSG, - _EditZone.MOVE_Y_AXIS: self._STATUSBAR_MOVE_AXIS_MSG, - _EditZone.DELETE_AXIS: self._STATUSBAR_DELETE_AXIS_MSG, + _EditZone.MOVE_AXIS: self._STATUSBAR_MOVE_AXIS_MSG, + _EditZone.MOVE_CENTER: self._STATUSBAR_MOVE_AXIS_MSG, + _EditZone.DISABLE: self._STATUSBAR_DELETE_AXIS_MSG, } msg = msgs.get(self.zone, None) if msg: statusbar.push(statusbar_cid, msg) - self._last_msg_zone = self.zone def get_options_widget(self): """Get the (class singleton) options widget""" @@ -174,7 +186,7 @@ def get_options_widget(self): ## Events and internals def button_press_cb(self, tdw, event): - if self.zone in (_EditZone.CREATE_AXIS, _EditZone.DELETE_AXIS): + if self.zone in (_EditZone.CREATE_AXIS, _EditZone.DISABLE): button = event.button if button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._click_info = (button, self.zone) @@ -186,15 +198,15 @@ def button_release_cb(self, tdw, event): button0, zone0 = self._click_info if event.button == button0: if self.zone == zone0: - model = tdw.doc - layer_stack = model.layer_stack - if zone0 == _EditZone.DELETE_AXIS: + layer_stack = tdw.doc.layer_stack + if zone0 == _EditZone.DISABLE: layer_stack.symmetry_active = False elif zone0 == _EditZone.CREATE_AXIS: x, y = tdw.display_to_model(event.x, event.y) - layer_stack.symmetry_x = x - layer_stack.symmetry_y = y - layer_stack.symmetry_active = True + x, y = int(round(x)), int(round(y)) + if layer_stack.symmetry_unset: + layer_stack.symmetry_unset = False + layer_stack.set_symmetry_state(True, center=(x, y)) self._click_info = None self._update_zone_and_cursor(tdw, event.x, event.y) return False @@ -212,142 +224,109 @@ def _update_zone_and_cursor(self, tdw, x, y): """ if self.in_drag: return - old_zone = self.zone new_zone = None - new_alphafrac = self.line_alphafrac - xm, ym = tdw.display_to_model(x, y) - model = tdw.doc - layer_stack = model.layer_stack - axis_x = layer_stack.symmetry_x - axis_y = layer_stack.symmetry_y + layer_stack = tdw.doc.layer_stack if not layer_stack.symmetry_active: self.active_cursor = self.cursor_add self.inactive_cursor = self.cursor_add new_zone = _EditZone.CREATE_AXIS - # Button hits. - # NOTE: the position is calculated by the related overlay, - # in its paint() method. + # Button hits - prioritize moving over disabling + if new_zone is None and self.center_pos: + cx, cy = self.center_pos + if math.hypot(cx - x, cy - y) <= self.CENTER_BUTTON_RADIUS: + self.active_cursor = self.cursor_moving + self.inactive_cursor = self.cursor_movable + new_zone = _EditZone.MOVE_CENTER + if new_zone is None and self.button_pos: bx, by = self.button_pos - d = math.hypot(bx-x, by-y) - if d <= gui.style.FLOATING_BUTTON_RADIUS: + if math.hypot(bx - x, by - y) <= self.DISABLE_BUTTON_RADIUS: self.active_cursor = self.cursor_remove self.inactive_cursor = self.cursor_remove - new_zone = _EditZone.DELETE_AXIS + new_zone = _EditZone.DISABLE + axis_changed = False if new_zone is None: - move_cursor_name, perp_dist = tdw.get_move_cursor_name_for_edge( - (x, y), - (axis_x, 0), - (axis_x, 1000), - tolerance=self._GRAB_SENSITIVITY, - finite=False, - ) - if move_cursor_name: - move_cursor = self._move_cursors.get(move_cursor_name) - if not move_cursor: - move_cursor = self.doc.app.cursors.get_action_cursor( - self.ACTION_NAME, - move_cursor_name, - ) - self._move_cursors[move_cursor_name] = move_cursor - self.active_cursor = move_cursor - self.inactive_cursor = move_cursor - new_zone = _EditZone.MOVE_X_AXIS - dfrac = lib.helpers.clamp( - perp_dist / (10.0 * self._GRAB_SENSITIVITY), - 0.0, 1.0, - ) - new_alphafrac = 1.0 - dfrac - - if new_zone is None: - move_cursor_name, perp_dist = tdw.get_move_cursor_name_for_edge( - (x, y), - (0, axis_y), - (1000, axis_y), - tolerance=self._GRAB_SENSITIVITY, - finite=False, - ) - if move_cursor_name: - move_cursor = self._move_cursors.get(move_cursor_name) - if not move_cursor: - move_cursor = self.doc.app.cursors.get_action_cursor( - self.ACTION_NAME, - move_cursor_name, - ) - self._move_cursors[move_cursor_name] = move_cursor - self.active_cursor = move_cursor - self.inactive_cursor = move_cursor - new_zone = _EditZone.MOVE_Y_AXIS - dfrac = lib.helpers.clamp( - perp_dist / (10.0 * self._GRAB_SENSITIVITY), - 0.0, 1.0, - ) - new_alphafrac = 1.0 - dfrac + new_zone, axis_changed = self._update_axis_status( + layer_stack, tdw, x, y) if new_zone is None: - new_zone = _EditZone.UNKNOWN + new_zone = _EditZone.NONE self.active_cursor = self.cursor_normal self.inactive_cursor = self.cursor_normal - if new_zone != old_zone: + if new_zone != self.zone or axis_changed: self.zone = new_zone self._update_statusbar() tdw.queue_draw() - elif new_alphafrac != self.line_alphafrac: - tdw.queue_draw() - self.line_alphafrac = new_alphafrac + + def _update_axis_status(self, stack, tdw, x, y): + """Check and record if cursor is within grabbing distance of an axis + + :param lib.layer.tree.RootLayerStack stack: + :param gui.tileddrawwidget.TiledDrawWidget tdw: + :return: (zone, active axis changed), or (None, None) if the cursor was + not within grabbing distaance of any visible axis line. + """ + # TODO: Change to NOT recalculate intersections every time + corners = tdw.get_corners_model_coords() + xm, ym = stack.symmetry_center + intersections = get_viewport_intersections( + stack.symmetry_type, xm, ym, + stack.symmetry_angle, stack.symmetry_lines, corners) + for i, p1, p2 in intersections: + cursor_name = tdw.get_move_cursor_name_for_edge( + (x, y), p1, p2, self._GRAB_SENSITIVITY) + if cursor_name: + a = math.atan2(p1[1] - p2[1], p1[0] - p2[0]) + self._drag_axis_p2 = ( + xm + math.cos(a), + ym + math.sin(a) + ) + axis_changed = self.active_axis != i + self.active_axis = i + cursor = self._get_cursor(cursor_name) + self.active_cursor = self.inactive_cursor = cursor + return _EditZone.MOVE_AXIS, axis_changed + + return None, None def motion_notify_cb(self, tdw, event): if not self.in_drag: + self.real_alpha = False self._update_zone_and_cursor(tdw, event.x, event.y) tdw.set_override_cursor(self.inactive_cursor) return super(SymmetryEditMode, self).motion_notify_cb(tdw, event) def drag_start_cb(self, tdw, event): - model = tdw.doc - layer_stack = model.layer_stack - self._update_zone_and_cursor(tdw, event.x, event.y) - if self.zone == _EditZone.MOVE_X_AXIS: - x0, y0 = self.start_x, self.start_y - self._drag_start_x = int(round(layer_stack.symmetry_x)) - x0_m, y0_m = tdw.display_to_model(x0, y0) - self._drag_start_model_x = x0_m - elif self.zone == _EditZone.MOVE_Y_AXIS: - x0, y0 = self.start_x, self.start_y - self._drag_start_y = int(round(layer_stack.symmetry_y)) - x0_m, y0_m = tdw.display_to_model(x0, y0) - self._drag_start_model_y = y0_m + if self.zone in {_EditZone.MOVE_AXIS, _EditZone.MOVE_CENTER}: + self._drag_start_pos = tdw.doc.layer_stack.symmetry_center + else: + self._update_zone_and_cursor(tdw, event.x, event.y) return super(SymmetryEditMode, self).drag_start_cb(tdw, event) def drag_update_cb(self, tdw, event, dx, dy): - if self.zone == _EditZone.MOVE_X_AXIS: - x_m, y_m = tdw.display_to_model(event.x, event.y) - x = self._drag_start_x + x_m - self._drag_start_model_x - x = int(round(x)) - if x != self._drag_start_x: - model = tdw.doc - layer_stack = model.layer_stack - layer_stack.symmetry_x = x - elif self.zone == _EditZone.MOVE_Y_AXIS: - x_m, y_m = tdw.display_to_model(event.x, event.y) - y = self._drag_start_y + y_m - self._drag_start_model_y - y = int(round(y)) - if y != self._drag_start_y: - model = tdw.doc - layer_stack = model.layer_stack - layer_stack.symmetry_y = y + xm, ym = tdw.display_to_model(event.x, event.y) + stack = tdw.doc.layer_stack + if self.zone == _EditZone.MOVE_CENTER: + stack.symmetry_center = (xm, ym) + elif self.zone == _EditZone.MOVE_AXIS: + xs, ys = self._drag_start_pos + xmc, ymc = lib.alg.nearest_point_on_line( + (xs, ys), self._drag_axis_p2, (xm, ym)) + stack.symmetry_center = ((xs - (xmc - xm)), (ys - (ymc - ym))) return super(SymmetryEditMode, self).drag_update_cb(tdw, event, dx, dy) def drag_stop_cb(self, tdw): - if self.zone == _EditZone.MOVE_X_AXIS: - tdw.queue_draw() - if self.zone == _EditZone.MOVE_Y_AXIS: - tdw.queue_draw() + tdw.queue_draw() return super(SymmetryEditMode, self).drag_stop_cb(tdw) +def is_symmetry_edit_mode(mode): + return isinstance(mode, SymmetryEditMode) + + class SymmetryEditOptionsWidget (Gtk.Alignment): _POSITION_LABEL_X_TEXT = C_( @@ -360,7 +339,7 @@ class SymmetryEditOptionsWidget (Gtk.Alignment): ) _ANGLE_LABEL_TEXT = C_( "symmetry axis options panel: labels", - u"Angle: %.1f°", + u"Angle: %.2f°", ) _POSITION_BUTTON_TEXT_INACTIVE = C_( "symmetry axis options panel: position button: no axis pos.", @@ -395,29 +374,30 @@ def __init__(self): self._axis_pos_y_dialog = None self._axis_pos_y_button = None self._symmetry_type_combo = None - self._axis_rot_sym_lines_entry = None + self._axis_sym_lines_entry = None from gui.application import get_app self.app = get_app() rootstack = self.app.doc.model.layer_stack + x, y = rootstack.symmetry_center self._axis_pos_adj_x = Gtk.Adjustment( - value=rootstack.symmetry_x, + value=x, upper=32000, lower=-32000, step_increment=1, page_increment=100, ) - self._axis_pos_adj_x.connect( + self._xpos_cb_id = self._axis_pos_adj_x.connect( 'value-changed', self._axis_pos_adj_x_changed, ) self._axis_pos_adj_y = Gtk.Adjustment( - value=rootstack.symmetry_y, + value=y, upper=32000, lower=-32000, step_increment=1, page_increment=100, ) - self._axis_pos_adj_y.connect( + self._ypos_cb_id = self._axis_pos_adj_y.connect( 'value-changed', self._axis_pos_adj_y_changed, ) @@ -428,23 +408,23 @@ def __init__(self): step_increment=1, page_increment=15, ) - self._axis_angle.connect("value-changed", self._angle_value_changed) - self._axis_rot_symmetry_lines = Gtk.Adjustment( - value=rootstack.rot_symmetry_lines, + self._angle_cb_id = self._axis_angle.connect( + "value-changed", self._angle_value_changed) + self._axis_symmetry_lines = Gtk.Adjustment( + value=rootstack.symmetry_lines, upper=50, lower=2, step_increment=1, page_increment=3, ) - self._axis_rot_symmetry_lines.connect( + self._lines_cb_id = self._axis_symmetry_lines.connect( 'value-changed', self._axis_rot_symmetry_lines_changed, ) self._init_ui() rootstack.symmetry_state_changed += self._symmetry_state_changed_cb - self._update_axis_pos_x_button_label(rootstack.symmetry_x) - self._update_axis_pos_y_button_label(rootstack.symmetry_y) + self._update_button_labels(rootstack) def _init_ui(self): app = self.app @@ -542,29 +522,17 @@ def _init_ui(self): row += 1 store = Gtk.ListStore(int, str) - sym_types = lib.tiledsurface.SYMMETRY_TYPES - active_idx = 0 rootstack = self.app.doc.model.layer_stack - starts_with_rotate = ( - rootstack.symmetry_type in - { - lib.mypaintlib.SymmetryRotational, - lib.mypaintlib.SymmetrySnowflake, - } - ) - for i, sym_type in enumerate(sym_types): - label = lib.tiledsurface.SYMMETRY_STRINGS.get(sym_type) - store.append([sym_type, label]) - if sym_type == rootstack.symmetry_type: - active_idx = i + for _type in lib.tiledsurface.SYMMETRY_TYPES: + store.append([_type, lib.tiledsurface.SYMMETRY_STRINGS[_type]]) self._symmetry_type_combo = Gtk.ComboBox() self._symmetry_type_combo.set_model(store) - self._symmetry_type_combo.set_active(active_idx) + self._symmetry_type_combo.set_active(rootstack.symmetry_type) self._symmetry_type_combo.set_hexpand(True) cell = Gtk.CellRendererText() self._symmetry_type_combo.pack_start(cell, True) self._symmetry_type_combo.add_attribute(cell, "text", 1) - self._symmetry_type_combo.connect( + self._type_cb_id = self._symmetry_type_combo.connect( 'changed', self._symmetry_type_combo_changed_cb ) @@ -578,12 +546,13 @@ def _init_ui(self): label = Gtk.Label(label=self._SYMMETRY_ROT_LINES_TEXT) label.set_hexpand(False) label.set_halign(Gtk.Align.START) - self._axis_rot_sym_lines_entry = Gtk.SpinButton( - adjustment=self._axis_rot_symmetry_lines, + self._axis_sym_lines_entry = Gtk.SpinButton( + adjustment=self._axis_symmetry_lines, climb_rate=0.25 ) + self._update_num_lines_sensitivity(rootstack.symmetry_type) grid.attach(label, 0, row, 1, 1) - grid.attach(self._axis_rot_sym_lines_entry, 1, row, 1, 1) + grid.attach(self._axis_sym_lines_entry, 1, row, 1, 1) row += 1 label = Gtk.Label(label=self._POSITION_LABEL_X_TEXT) @@ -612,12 +581,11 @@ def _init_ui(self): self._axis_pos_y_button = button row += 1 - label = Gtk.Label( - label=self._ANGLE_LABEL_TEXT % self._axis_angle.get_value() - ) + label = Gtk.Label() label.set_hexpand(False) label.set_halign(Gtk.Align.START) self._angle_label = label + self._update_angle_label() grid.attach(label, 0, row, 1, 1) scale = Gtk.Scale( orientation=Gtk.Orientation.HORIZONTAL, @@ -640,85 +608,65 @@ def _init_ui(self): grid.attach(button, 1, row, 2, 1) self._axis_active_button = button + def _update_angle_label(self): + self._angle_label.set_text( + self._ANGLE_LABEL_TEXT % self._axis_angle.get_value() + ) def _symmetry_state_changed_cb( - self, rootstack, active, x, y, - symmetry_type, rot_symmetry_lines, symmetry_angle): - self._update_axis_pos_x_button_label(x) - self._update_axis_pos_y_button_label(y) - dialog = self._axis_pos_x_dialog - dialog_content_box = dialog.get_content_area() - - if x is None: - dialog_content_box.set_sensitive(False) - else: - dialog_content_box.set_sensitive(True) - adj = self._axis_pos_adj_x - adj_pos = int(adj.get_value()) - model_pos = int(x) - if adj_pos != model_pos: - adj.set_value(model_pos) - - dialog = self._axis_pos_y_dialog - dialog_content_box = dialog.get_content_area() - if y is None: - dialog_content_box.set_sensitive(False) - else: - dialog_content_box.set_sensitive(True) - adj = self._axis_pos_adj_y - adj_pos = int(adj.get_value()) - model_pos = int(y) - if adj_pos != model_pos: - adj.set_value(model_pos) - - rotational_allowed = { - lib.mypaintlib.SymmetryRotational, - lib.mypaintlib.SymmetrySnowflake, - None, - } - if symmetry_type in rotational_allowed: - self._axis_rot_sym_lines_entry.set_sensitive(True) - else: - self._axis_rot_sym_lines_entry.set_sensitive(False) + self, stack, active, center, sym_type, sym_lines, sym_angle): + + if center: + cx, cy = center + with self._axis_pos_adj_x.handler_block(self._xpos_cb_id): + self._axis_pos_adj_x.set_value(cx) + with self._axis_pos_adj_y.handler_block(self._ypos_cb_id): + self._axis_pos_adj_y.set_value(cy) + if sym_type is not None: + with self._symmetry_type_combo.handler_block(self._type_cb_id): + self._symmetry_type_combo.set_active(sym_type) + self._update_num_lines_sensitivity(sym_type) + if sym_lines is not None: + with self._axis_symmetry_lines.handler_block(self._lines_cb_id): + self._axis_symmetry_lines.set_value(sym_lines) + if sym_angle is not None: + with self._axis_angle.handler_block(self._angle_cb_id): + self._axis_angle.set_value(sym_angle) + self._update_angle_label() + if center or stack.symmetry_unset: + self._update_button_labels(stack) + + def _update_num_lines_sensitivity(self, sym_type): + self._axis_sym_lines_entry.set_sensitive( + sym_type in {SymmetryRotational, SymmetrySnowflake} + ) - def _update_axis_pos_x_button_label(self, x): - if x is None: - text = self._POSITION_BUTTON_TEXT_INACTIVE + def _update_button_labels(self, stack): + if stack.symmetry_unset: + x, y = None, None else: - text = self._POSITION_BUTTON_TEXT_TEMPLATE % (x,) - self._axis_pos_x_button.set_label(text) + x, y = stack.symmetry_center + self._update_axis_button_label(self._axis_pos_x_button, x) + self._update_axis_button_label(self._axis_pos_y_button, y) - def _update_axis_pos_y_button_label(self, y): - if y is None: - text = self._POSITION_BUTTON_TEXT_INACTIVE + def _update_axis_button_label(self, button, value): + if value is None: + button.set_label(self._POSITION_BUTTON_TEXT_INACTIVE) else: - text = self._POSITION_BUTTON_TEXT_TEMPLATE % (y,) - self._axis_pos_y_button.set_label(text) + button.set_label(self._POSITION_BUTTON_TEXT_TEMPLATE % value) def _axis_pos_adj_x_changed(self, adj): - rootstack = self.app.doc.model.layer_stack - model_pos = int(rootstack.symmetry_x) - adj_pos = int(adj.get_value()) - if adj_pos != model_pos: - rootstack.symmetry_x = adj_pos + self.app.doc.model.layer_stack.symmetry_x = int(adj.get_value()) def _axis_rot_symmetry_lines_changed(self, adj): - rootstack = self.app.doc.model.layer_stack - sym_lines = int(rootstack.rot_symmetry_lines) - adj_pos = int(adj.get_value()) - rootstack.rot_symmetry_lines = adj_pos + self.app.doc.model.layer_stack.symmetry_lines = int(adj.get_value()) def _axis_pos_adj_y_changed(self, adj): - rootstack = self.app.doc.model.layer_stack - model_pos = int(rootstack.symmetry_y) - adj_pos = int(adj.get_value()) - if adj_pos != model_pos: - rootstack.symmetry_y = adj_pos + self.app.doc.model.layer_stack.symmetry_y = int(adj.get_value()) def _angle_value_changed(self, adj): - angle = adj.get_value() - self._angle_label.set_text(self._ANGLE_LABEL_TEXT % angle) - self.app.doc.model.layer_stack.symmetry_angle = angle + self._update_angle_label() + self.app.doc.model.layer_stack.symmetry_angle = adj.get_value() def _axis_pos_x_button_clicked_cb(self, button): self._axis_pos_x_dialog.show_all() @@ -726,23 +674,18 @@ def _axis_pos_x_button_clicked_cb(self, button): def _axis_pos_y_button_clicked_cb(self, button): self._axis_pos_y_dialog.show_all() - def _symmetry_type_combo_changed_cb(self, *ignored): - rootstack = self.app.doc.model.layer_stack - model = self._symmetry_type_combo.get_model() - mode = model.get_value(self._symmetry_type_combo.get_active_iter(), 0) - - if rootstack.symmetry_type == mode: - return - rootstack.symmetry_type = mode + def _symmetry_type_combo_changed_cb(self, combo): + sym_type = combo.get_model()[combo.get_active()][0] + self.app.doc.model.layer_stack.symmetry_type = sym_type def _axis_pos_dialog_response_cb(self, dialog, response_id): if response_id == Gtk.ResponseType.ACCEPT: dialog.hide() - def _scale_value_changed_cb(self, scale): - alpha = scale.get_value() - prefs = self.app.preferences - prefs[_ALPHA_PREFS_KEY] = alpha + def _scale_value_changed_cb(self, alpha_scale): + self.app.preferences[_ALPHA_PREFS_KEY] = alpha_scale.get_value() + edit_mode = self.app.doc.modes.top + edit_mode.real_alpha = True for tdw in self._tdws_with_symmetry_overlays(): tdw.queue_draw() @@ -759,24 +702,40 @@ class SymmetryOverlay (gui.overlays.Overlay): _DASH_PATTERN = [10, 7] _DASH_OFFSET = 5 + _EDIT_MODE_MIN_ALPHA = 0.25 def __init__(self, doc): gui.overlays.Overlay.__init__(self) self.doc = doc self.tdw = self.doc.tdw + self.tdw.connect("enter-notify-event", self._enter_notify_cb) rootstack = doc.model.layer_stack rootstack.symmetry_state_changed += self._symmetry_state_changed_cb doc.modes.changed += self._active_mode_changed_cb self._trash_icon_pixbuf = None + def _enter_notify_cb(self, tdw, event): + edit_mode = self._get_edit_mode() + if edit_mode and edit_mode.real_alpha: + edit_mode.real_alpha = False + self.tdw.queue_draw() + + @property + def trash_icon_pixbuf(self): + if not self._trash_icon_pixbuf: + self._trash_icon_pixbuf = gui.drawutils.load_symbolic_icon( + icon_name="mypaint-trash-symbolic", + size=SymmetryEditMode.DISABLE_BUTTON_RADIUS, + fg=(0, 0, 0, 1), + ) + return self._trash_icon_pixbuf + def _symmetry_state_changed_cb(self, *args, **kwargs): self.tdw.queue_draw() def _active_mode_changed_cb(self, mode_stack, old, new): - for mode in (old, new): - if isinstance(mode, SymmetryEditMode): - self.tdw.queue_draw() - break + if any(map(is_symmetry_edit_mode, (old, new))): + self.tdw.queue_draw() def paint(self, cr): """Paint the overlay, in display coordinates""" @@ -785,218 +744,144 @@ def paint(self, cr): model = self.doc.model if not model.layer_stack.symmetry_active: return - axis_x_m = model.layer_stack.symmetry_x - axis_y_m = model.layer_stack.symmetry_y - axis_symmetry_type = model.layer_stack.symmetry_type - axis_rot_symmetry_lines = model.layer_stack.rot_symmetry_lines - # allocation, in display coords + # allocation, in display coordinates alloc = self.tdw.get_allocation() - view_x0, view_y0 = alloc.x, alloc.y - view_x1, view_y1 = view_x0+alloc.width, view_y0+alloc.height - view_center = ((view_x1-view_x0)/2.0, (view_y1-view_y0)/2.0) - - # Viewing rectangle extents, in model coords - viewport_corners = [ - (view_x0, view_y0), - (view_x0, view_y1), - (view_x1, view_y1), - (view_x1, view_y0), - ] - viewport_corners_m = [ - self.tdw.display_to_model(*c) - for c in viewport_corners - ] - - # Viewport extent in x in model space - min_corner_x_m = min([c_m[0] for c_m in viewport_corners_m]) - max_corner_x_m = max([c_m[0] for c_m in viewport_corners_m]) - min_corner_y_m = min([c_m[1] for c_m in viewport_corners_m]) - max_corner_y_m = max([c_m[1] for c_m in viewport_corners_m]) - - # symmetry axes extents - axis_x_p_min = (axis_x_m, min_corner_y_m) - axis_x_p_max = (axis_x_m, max_corner_y_m) - axis_y_p_min = (min_corner_x_m, axis_y_m) - axis_y_p_max = (max_corner_x_m, axis_y_m) - - # The places where the axes intersect the viewing rectangle - if axis_symmetry_type == lib.mypaintlib.SymmetryVertical: - intersections = [ - lib.alg.intersection_of_segments(p1, p2, axis_x_p_min, axis_x_p_max) - for (p1, p2) in lib.alg.pairwise(viewport_corners_m) - ] - elif axis_symmetry_type == lib.mypaintlib.SymmetryHorizontal: - intersections = [ - lib.alg.intersection_of_segments(p1, p2, axis_y_p_min, axis_y_p_max) - for (p1, p2) in lib.alg.pairwise(viewport_corners_m) - ] - else: - intersections = [] - axes_extents_m = [ - axis_x_p_min, axis_x_p_max, - axis_y_p_min, axis_y_p_max, - ] - for axes_extent_m1, axes_extent_m2 in lib.helpers.grouper(axes_extents_m, 2): - for (p1, p2) in lib.alg.pairwise(viewport_corners_m): - intersections.append( - lib.alg.intersection_of_segments(p1, p2, axes_extent_m1, axes_extent_m2) - ) - - intersections = [p for p in intersections if p is not None] - - len_intersections = len(intersections) - - if len_intersections < 2: + vx0, vy0 = alloc.x, alloc.y + vx1, vy1 = vx0 + alloc.width, vy0 + alloc.height + + corners_m = self.tdw.get_corners_model_coords() + mx, my = model.layer_stack.symmetry_center + angle = model.layer_stack.symmetry_angle + symmetry_type = model.layer_stack.symmetry_type + num_lines = model.layer_stack.symmetry_lines + + intersections = get_viewport_intersections( + symmetry_type, mx, my, angle, num_lines, corners_m) + + edit_mode = self._get_edit_mode() + + if intersections: + self._render_axis_lines(cr, intersections, edit_mode) + + if not edit_mode: return - if len_intersections % 2 == 1: - intersections.pop() + self._draw_disable_button( + edit_mode, mx, my, corners_m, cr, vx0, vx1, vy0, vy1) - # Back to display coords, with rounding and pixel centring - ax_points = [] - for intsc_m in intersections: - ax_point = tuple((math.floor(c) for c in self.tdw.model_to_display(*intsc_m))) - ax_points.append(ax_point) + # Draw center button if it intersects the viewport + r = SymmetryEditMode.CENTER_BUTTON_RADIUS + dx, dy = self.tdw.model_to_display(mx, my) + if (vx0 - r) < dx < (vx1 + r) and (vy0 - r) < dy < (vy1 + r): + col = self._item_color(edit_mode.zone == _EditZone.MOVE_CENTER) + gui.drawutils.render_round_floating_color_chip(cr, dx, dy, col, r) + edit_mode.center_pos = (dx, dy) + else: + edit_mode.center_pos = None - # Paint the symmetry axis - cr.save() + def _get_edit_mode(self): + edit_mode = tuple(filter(is_symmetry_edit_mode, self.doc.modes)) + return edit_mode[0] if edit_mode else None - cr.push_group() + def _draw_disable_button( + self, edit_mode, x, y, + corners_model, cr, vx0, vx1, vy0, vy1): + # Positioning strategy: If the center is within the viewport, + # the button is placed to the left of the center. Otherwise it is + # placed in the location closest to the center. That way it can also + # be used to locate the center visually. - cr.set_line_cap(cairo.LINE_CAP_SQUARE) - cr.set_dash(self._DASH_PATTERN, self._DASH_OFFSET) + button_pos_m = lib.alg.nearest_point_in_poly( + corners_model, (x, y)) + bpx, bpy = self.tdw.model_to_display(*button_pos_m) - mode_stack = self.doc.modes - active_edit_mode = None + # Constrain position to viewport (display coordinates) + margin = 2 * SymmetryEditMode.DISABLE_BUTTON_RADIUS + bpx = clamp(bpx - margin, vx0 + margin, vx1 - margin) + bpy = clamp(bpy, vy0 + margin, vy1 - margin) - for mode in reversed(list(mode_stack)): - if isinstance(mode, SymmetryEditMode): - active_edit_mode = mode - break + gui.drawutils.render_round_floating_button( + cr, bpx, bpy, + self._item_color(edit_mode.zone == _EditZone.DISABLE), + pixbuf=self.trash_icon_pixbuf, + ) + edit_mode.button_pos = (bpx, bpy) - prefs = self.tdw.app.preferences - min_alpha = float(prefs.get(_ALPHA_PREFS_KEY, _DEFAULT_ALPHA)) - max_alpha = 1.0 + def _render_axis_lines(self, cr, intersections, mode): + # Convert to display coordinates, with rounding and pixel centering + def convert(n): + x, y = self.tdw.model_to_display(*n) + return math.floor(x), math.floor(y) + points = [(i, convert(p1), convert(p2)) for i, p1, p2 in intersections] - if not active_edit_mode: - line_alpha = min_alpha - elif mode.zone in {_EditZone.MOVE_X_AXIS, _EditZone.MOVE_Y_AXIS}: - line_alpha = max_alpha - else: - line_alpha = min_alpha + ( - active_edit_mode.line_alphafrac * (max_alpha-min_alpha) - ) + prefs = self.tdw.app.preferences + line_alpha = float(prefs.get(_ALPHA_PREFS_KEY, _DEFAULT_ALPHA)) + if mode and mode.zone in {_EditZone.MOVE_AXIS, _EditZone.MOVE_CENTER}: + line_alpha = 1.0 + elif mode and not mode.real_alpha: + line_alpha = max(self._EDIT_MODE_MIN_ALPHA, line_alpha) - line_width = gui.style.DRAGGABLE_EDGE_WIDTH - if line_width % 2 != 0: - for ax_point in ax_points: - ax_point[0] += 0.5 - ax_point[1] += 0.5 - - cr.set_line_width(line_width) - - for ax_point, ax_point2 in lib.helpers.grouper(ax_points, 2): - if not active_edit_mode: - line_color = gui.style.EDITABLE_ITEM_COLOR - elif ax_point[0] == ax_point2[0]: - if mode.zone == _EditZone.MOVE_X_AXIS: - line_color = gui.style.ACTIVE_ITEM_COLOR - else: - line_color = gui.style.EDITABLE_ITEM_COLOR - elif ax_point[1] == ax_point2[1]: - if mode.zone == _EditZone.MOVE_Y_AXIS: - line_color = gui.style.ACTIVE_ITEM_COLOR - else: - line_color = gui.style.EDITABLE_ITEM_COLOR - else: - line_color = gui.style.EDITABLE_ITEM_COLOR + if line_alpha <= 0: + return + # Paint the symmetry axis + cr.save() + cr.push_group() + cr.set_line_cap(cairo.LINE_CAP_SQUARE) + cr.set_dash(self._DASH_PATTERN, self._DASH_OFFSET) + cr.set_line_width(gui.style.DRAGGABLE_EDGE_WIDTH) + for i, (x0, y0), (x1, y1) in points: + # Draw all axes as active if center is being moved, otherwise only + # draw an axis as active if it is currently being moved. + active = mode and (mode.zone == _EditZone.MOVE_CENTER + or mode.active_axis == i + and mode.zone == _EditZone.MOVE_AXIS) + line_color = SymmetryOverlay._item_color(active) cr.set_source_rgb(*line_color.get_rgb()) - cr.move_to(*ax_point2) - cr.line_to(*ax_point) + cr.move_to(x0, y0) + cr.line_to(x1, y1) gui.drawutils.render_drop_shadow(cr, z=1) cr.stroke() - cr.pop_group_to_source() cr.paint_with_alpha(line_alpha) - cr.restore() - if not active_edit_mode: - return - - # Remove button - - # Positioning strategy: the point on the axis line - # which is closest to the centre of the viewport. - if axis_symmetry_type == lib.mypaintlib.SymmetryVertical: - ax_x0, ax_y0 = ax_points[0] - ax_x1, ax_y1 = ax_points[1] - elif axis_symmetry_type == lib.mypaintlib.SymmetryHorizontal: - ax_x0, ax_y0 = ax_points[0] - ax_x1, ax_y1 = ax_points[1] - else: - x_axis_dist = abs(ax_points[0][0] - view_center[0])/alloc.width - y_axis_dist = abs(ax_points[-1][1] - view_center[1])/alloc.height - if y_axis_dist < x_axis_dist: - ax_x0, ax_y0 = ax_points[-1] - ax_x1, ax_y1 = ax_points[-2] - else: - ax_x0, ax_y0 = ax_points[0] - ax_x1, ax_y1 = ax_points[1] - - button_pos = lib.alg.nearest_point_in_segment( - seg_start=(ax_x0, ax_y0), - seg_end=(ax_x1, ax_y1), - point=view_center, - ) - - if button_pos is None: - d0 = math.hypot(view_center[0]-ax_x0, view_center[1]-ax_y0) - d1 = math.hypot(view_center[0]-ax_x1, view_center[1]-ax_y1) - if d0 < d1: - button_pos = (ax_x0, ax_y0) - else: - button_pos = (ax_x1, ax_y1) - assert button_pos is not None - button_pos = [math.floor(c) for c in button_pos] - - # Constrain the position so that it appears within the viewport - margin = 2 * gui.style.FLOATING_BUTTON_RADIUS - button_pos = [ - lib.helpers.clamp( - button_pos[0], - view_x0 + margin, - view_x1 - margin, - ), - lib.helpers.clamp( - button_pos[1], - view_y0 + margin, - view_y1 - margin, - ), - ] - - if not self._trash_icon_pixbuf: - self._trash_icon_pixbuf = gui.drawutils.load_symbolic_icon( - icon_name="mypaint-trash-symbolic", - size=gui.style.FLOATING_BUTTON_ICON_SIZE, - fg=(0, 0, 0, 1), - ) - icon_pixbuf = self._trash_icon_pixbuf - - if active_edit_mode.zone == _EditZone.DELETE_AXIS: - button_color = gui.style.ACTIVE_ITEM_COLOR + @staticmethod + def _item_color(active): + if active: + return gui.style.ACTIVE_ITEM_COLOR else: - button_color = gui.style.EDITABLE_ITEM_COLOR - - gui.drawutils.render_round_floating_button( - cr=cr, - x=button_pos[0], - y=button_pos[1], - color=button_color, - radius=gui.style.FLOATING_BUTTON_RADIUS, - pixbuf=icon_pixbuf, - ) - active_edit_mode.button_pos = button_pos + return gui.style.EDITABLE_ITEM_COLOR + + +def get_viewport_intersections(symm_type, x, y, angle, num_lines, corners_m): + """Get indexed tuples with pairs of coordinates for each intersection + + The returned data is of the form [(index, (x0, y0), (x1, y1)), ...] where + the index canonically identifies an axis, when there are multiple. Index + values are partially ordered, but not always contiguous. + + If there are no intersections, the empty list is returned. + """ + intersections = [] + p1 = (x, y) + angle = math.pi * ((angle % 360) / 180) + + def append(a, **kwargs): + # Reflected on y axis, due to display direction + inter = lib.alg.intersection_of_vector_and_poly( + corners_m, p1, (x + math.cos(a), y - math.sin(a)), **kwargs) + intersections.append(inter) + + if symm_type in {SymmetryHorizontal, SymmetryVertHorz}: + append(angle) + if symm_type in {SymmetryVertical, SymmetryVertHorz}: + append(angle + math.pi / 2) + elif symm_type in {SymmetryRotational, SymmetrySnowflake}: + delta = (math.pi * 2) / num_lines + for i in range(num_lines): + a = angle + delta * i + append(a, line_type=lib.alg.LineType.DIRECTIONAL) + return [(i, p[0], p[1]) for i, p in enumerate(intersections) if p] diff --git a/gui/tileddrawwidget.py b/gui/tileddrawwidget.py index 59fdfea68..e4702a80d 100644 --- a/gui/tileddrawwidget.py +++ b/gui/tileddrawwidget.py @@ -31,6 +31,7 @@ from .drawutils import render_checks import gui.style import lib.color +import lib.alg from lib.pycompat import xrange logger = logging.getLogger(__name__) @@ -479,8 +480,8 @@ def get_move_cursor_name_for_edge(self, cursor_pos, edge_p1, edge_p2, :param tuple edge_p2: point on the edge, as model (x, y) :param int tolerance: slack for cursor pos., in display pixels :param bool finite: if false, the edge extends beyond p1, p2 - :returns: move direction cursor & distance from line (cursor, d) - :rtype: tuple + :returns: move direction cursor string, or None + :rtype: str This can be used by special input modes when resizing objects on screen, for example frame edges and the symmetry axis. @@ -494,37 +495,23 @@ def get_move_cursor_name_for_edge(self, cursor_pos, edge_p1, edge_p2, * gui.cursor.Name (naming consts for cursors) """ - # Work in screen pixels only - x0, y0 = cursor_pos - x1, y1 = self.model_to_display(*edge_p1) - x2, y2 = self.model_to_display(*edge_p2) - dx = x2 - x1 - dy = y2 - y1 - edge_len = math.sqrt(dx**2 + dy**2) - if edge_len <= 0: - dist1 = math.hypot(x0 - x1, y0 - y1) - dist2 = math.hypot(x0 - x2, y0 - y2) - return (None, min(dist1, dist2)) - # Perpendicular distance from a line. - two_triarea = abs((dy * x0) - (dx * y0) - (x1 * y2) + (x2 * y1)) - perp_dist = two_triarea / edge_len - if perp_dist > tolerance: - return (None, perp_dist) - # Rough approximation here, but good enough for hit calculation. - # Omit points too far away from the edge's centre point. if finite: - xmid = (x1 + x2) / 2.0 - ymid = (y1 + y2) / 2.0 - dist_from_midpt = math.sqrt(((xmid - x0) ** 2) + - ((ymid - y0) ** 2)) - if dist_from_midpt > ((edge_len / 2.0) + tolerance): - return (None, perp_dist) - # Cursor name by sector. - # Aiming for a cursor that looks perpendicular to the line. - # Ish. - theta = math.atan2(dy, dx) - c = cursor.get_move_cursor_name_for_angle(theta + (math.pi / 2)) - return (c, perp_dist) + nearest_point = lib.alg.nearest_point_on_segment + else: + nearest_point = lib.alg.nearest_point_on_line + x0, y0 = cursor_pos + p1 = self.model_to_display(*edge_p1) + p2 = self.model_to_display(*edge_p2) + closest = nearest_point(p1, p2, cursor_pos) + if closest: + x1, y1 = closest + if (x0 - x1)**2 + (y0 - y1)**2 > tolerance**2: + return None + dx = p1[0] - p2[0] + dy = p1[1] - p2[1] + # Cursor name by angle - closest to being perpendicular to edge. + edge_angle_perp = math.atan2(-dy, dx) + math.pi / 2 + return cursor.get_move_cursor_name_for_angle(edge_angle_perp) class CanvasTransformation (object): diff --git a/lib/document.py b/lib/document.py index c0306e222..b48082d32 100644 --- a/lib/document.py +++ b/lib/document.py @@ -929,11 +929,9 @@ def clear(self, new_cache=True): """ self.sync_pending_changes() self.layer_view_manager.clear() + self._layers.symmetry_unset = True self._layers.set_symmetry_state( - False, None, None, - lib.mypaintlib.SymmetryVertical, 2, - 0 - ) + False, (0, 0), lib.mypaintlib.SymmetryVertical, 2, 0) prev_area = self.get_full_redraw_bbox() if self._owns_cache_dir: if self._cache_dir is not None: diff --git a/lib/layer/core.py b/lib/layer/core.py index a612c5b0a..4d3b5f673 100644 --- a/lib/layer/core.py +++ b/lib/layer/core.py @@ -853,17 +853,16 @@ def _get_stackxml_element(self, tag, x=None, y=None): ## Painting symmetry axis - def set_symmetry_state(self, active, center_x, center_y, - symmetry_type, rot_symmetry_lines, symmetry_angle): + def set_symmetry_state(self, active, center, + symmetry_type, symmetry_lines, angle): """Set the surface's painting symmetry axis and active flag. :param bool active: Whether painting should be symmetrical. - :param int center_x: X coord of the axis of symmetry. - :param int center_y: Y coord of the axis of symmetry. + :param tuple center: (x, y) coordinates of the center of symmetry :param int symmetry_type: symmetry type that will be applied if active - :param int rot_symmetry_lines: number of rotational + :param int symmetry_lines: number of rotational symmetry lines for angle dependent symmetry modes. - :param int symmetry_angle: The angle of the symmetry line(s) + :param float angle: The angle of the symmetry line(s) The symmetry axis is only meaningful to paintable layers. Received strokes are reflected along the line ``x=center_x`` diff --git a/lib/layer/data.py b/lib/layer/data.py index eb9e2059a..6e2ad8472 100644 --- a/lib/layer/data.py +++ b/lib/layer/data.py @@ -489,17 +489,18 @@ def _save_rect_to_ora(self, orazip, tmpdir, prefix, path, ## Painting symmetry axis - def set_symmetry_state(self, active, center_x, center_y, - symmetry_type, rot_symmetry_lines, symmetry_angle): + def set_symmetry_state( + self, active, center, symmetry_type, symmetry_lines, angle): """Set the surface's painting symmetry axis and active flag. See `LayerBase.set_symmetry_state` for the params. """ + cx, cy = center self._surface.set_symmetry_state( bool(active), - float(center_x), float(center_y), - int(symmetry_type), int(rot_symmetry_lines), - float(symmetry_angle) + float(cx), float(cy), + int(symmetry_type), int(symmetry_lines), + float(angle) ) ## Snapshots diff --git a/lib/layer/tree.py b/lib/layer/tree.py index 213e94b10..a345b6a3c 100644 --- a/lib/layer/tree.py +++ b/lib/layer/tree.py @@ -131,13 +131,15 @@ def __init__(self, doc=None, self._default_background = default_bg self._background_layer = data.BackgroundLayer(default_bg) self._background_visible = True - # Symmetry - self._symmetry_x = None - self._symmetry_y = None - self._symmetry_type = None - self._symmetry_angle = 0 - self._rot_symmetry_lines = 2 + # Symmetry - the `unset` flag is used to decide on whether to reset + # the symmetry state - to e.g. set the center based on the viewport, + # or the document bounds. + self._symmetry_unset = True self._symmetry_active = False + self._symmetry_type = lib.mypaintlib.SymmetryVertical + self._symmetry_center = (0, 0) + self._symmetry_angle = 0 + self._symmetry_lines = 2 # Special rendering state self._current_layer_solo = False self._current_layer_previewing = False @@ -775,7 +777,15 @@ def get_render_ops(self, spec): ops.extend(spec.global_overlay.get_render_ops(spec)) return ops - ## Symmetry axis + # Symmetry state + + @property + def symmetry_unset(self): + return self._symmetry_unset + + @symmetry_unset.setter + def symmetry_unset(self, unset): + self._symmetry_unset = bool(unset) @property def symmetry_active(self): @@ -788,90 +798,31 @@ def symmetry_active(self): @symmetry_active.setter def symmetry_active(self, active): - if self._symmetry_x is None: - raise ValueError( - "UI code must set a non-Null symmetry_x " - "before activating symmetrical painting." - ) - if self._symmetry_y is None: - raise ValueError( - "UI code must set a non-Null symmetry_y " - "before activating symmetrical painting." - ) - if self._symmetry_type is None: - raise ValueError( - "UI code must set a non-Null symmetry_type " - "before activating symmetrical painting." - ) - self.set_symmetry_state( - active, - self._symmetry_x, self._symmetry_y, - self._symmetry_type, self.rot_symmetry_lines, - self.symmetry_angle - ) + self.set_symmetry_state(active) - # should be combined into one prop for less event firing @property - def symmetry_y(self): - """The active painting symmetry Y axis value - - The `symmetry_y` property may be set to None. - This indicates the initial state of a document when - it has been newly created, or newly opened from a file. + def symmetry_center(self): + return self._symmetry_center - Setting the property to a value forces `symmetry_active` on, - and setting it to ``None`` forces `symmetry_active` off. - In both bases, only one `symmetry_state_changed` gets emitted. - - This is a convenience property for part of - the state managed by `set_symmetry_state()`. - """ - return self._symmetry_y + @symmetry_center.setter + def symmetry_center(self, center): + self.set_symmetry_state(True, center=center) @property def symmetry_x(self): - """The active painting symmetry X axis value - - The `symmetry_x` property may be set to None. - This indicates the initial state of a document when - it has been newly created, or newly opened from a file. - - Setting the property to a value forces `symmetry_active` on, - and setting it to ``None`` forces `symmetry_active` off. - In both bases, only one `symmetry_state_changed` gets emitted. - - This is a convenience property for part of - the state managed by `set_symmetry_state()`. - """ - return self._symmetry_x + return self._symmetry_center[0] @symmetry_x.setter def symmetry_x(self, x): - if x is None: - self.set_symmetry_state(False, None, None, None, None, 0) - else: - self.set_symmetry_state( - True, - x, - self._symmetry_y, - self._symmetry_type, - self._rot_symmetry_lines, - self._symmetry_angle, - ) + self.set_symmetry_state(True, center=(x, self._symmetry_center[1])) + + @property + def symmetry_y(self): + return self._symmetry_center[1] @symmetry_y.setter def symmetry_y(self, y): - if y is None: - self.set_symmetry_state(False, None, None, None, None, 0) - else: - self.set_symmetry_state( - True, - self._symmetry_x, - y, - self._symmetry_type, - self._rot_symmetry_lines, - self._symmetry_angle, - ) + self.set_symmetry_state(True, center=(self._symmetry_center[0], y)) @property def symmetry_type(self): @@ -879,35 +830,15 @@ def symmetry_type(self): @symmetry_type.setter def symmetry_type(self, symmetry_type): - if symmetry_type is None: - self.set_symmetry_state(False, None, None, None, None, 0) - else: - self.set_symmetry_state( - True, - self._symmetry_x, - self._symmetry_y, - symmetry_type, - self._rot_symmetry_lines, - self._symmetry_angle, - ) + self.set_symmetry_state(True, symmetry_type=symmetry_type) @property - def rot_symmetry_lines(self): - return self._rot_symmetry_lines + def symmetry_lines(self): + return self._symmetry_lines - @rot_symmetry_lines.setter - def rot_symmetry_lines(self, rot_symmetry_lines): - if rot_symmetry_lines is None: - self.set_symmetry_state(False, None, None, None, None, 0) - else: - self.set_symmetry_state( - True, - self._symmetry_x, - self._symmetry_y, - self._symmetry_type, - rot_symmetry_lines, - self._symmetry_angle, - ) + @symmetry_lines.setter + def symmetry_lines(self, symmetry_lines): + self.set_symmetry_state(True, symmetry_lines=symmetry_lines) @property def symmetry_angle(self): @@ -915,21 +846,12 @@ def symmetry_angle(self): @symmetry_angle.setter def symmetry_angle(self, symmetry_angle): - if symmetry_angle is None: - self.set_symmetry_state(False, None, None, None, None, 0) - else: - self.set_symmetry_state( - True, - self._symmetry_x, - self._symmetry_y, - self._symmetry_type, - self._rot_symmetry_lines, - symmetry_angle, - ) + self.set_symmetry_state(True, angle=symmetry_angle) - def set_symmetry_state(self, active, center_x, center_y, - symmetry_type, rot_symmetry_lines, symmetry_angle): - """Set the central, propagated, symmetry axis and active flag. + def set_symmetry_state( + self, active=None, center=None, + symmetry_type=None, symmetry_lines=None, angle=None): + """Set the central, propagated, symmetry state. The root layer stack specialization manages a central state, which is propagated to the current layer automatically. @@ -939,78 +861,53 @@ def set_symmetry_state(self, active, center_x, center_y, see `symmetry_x` for what that means. """ - active = bool(active) - if center_x is not None: - center_x = round(float(center_x)) - if center_y is not None: - center_y = round(float(center_y)) + if active is not None: + active = bool(active) + self._symmetry_active = active + if center is not None: + center = int(round(center[0])), int(round(center[1])) + self._symmetry_center = center if symmetry_type is not None: symmetry_type = int(symmetry_type) - if rot_symmetry_lines is not None: - rot_symmetry_lines = int(rot_symmetry_lines) + self._symmetry_type = symmetry_type + if symmetry_lines is not None: + symmetry_lines = int(symmetry_lines) + self._symmetry_lines = symmetry_lines + if angle is not None: + self._symmetry_angle = angle - oldstate = ( - self._symmetry_active, - self._symmetry_x, - self._symmetry_y, - self._symmetry_type, - self._rot_symmetry_lines, - self._symmetry_angle, - ) - newstate = ( - active, - center_x, - center_y, - symmetry_type, - rot_symmetry_lines, - symmetry_angle, - ) - if oldstate == newstate: - return - self._symmetry_active = active - self._symmetry_x = center_x - self._symmetry_y = center_y - self._symmetry_type = symmetry_type - self._rot_symmetry_lines = rot_symmetry_lines - self._symmetry_angle = symmetry_angle current = self.get_current() if current is not self: self._propagate_symmetry_state(current) self.symmetry_state_changed( - active, - center_x, - center_y, - symmetry_type, - rot_symmetry_lines, - symmetry_angle, + active, center, symmetry_type, symmetry_lines, angle, ) def _propagate_symmetry_state(self, layer): """Copy the symmetry state to the a descendant layer""" assert layer is not self - if None in {self._symmetry_x, self._symmetry_y, self._symmetry_type}: - return layer.set_symmetry_state( self._symmetry_active, - self._symmetry_x, - self._symmetry_y, + self._symmetry_center, self._symmetry_type, - self._rot_symmetry_lines, + self._symmetry_lines, self._symmetry_angle, ) @event - def symmetry_state_changed(self, active, x, y, - symmetry_type, rot_symmetry_lines, - symmetry_angle): - """Event: symmetry axis was changed, or was toggled - - :param bool active: updated `symmetry_active` value - :param int x: new symmetry reference point X - :param int y: new symmetry reference point Y - :param int symmetry_type: symmetry type - :param int rot_symmetry_lines: new number of lines - :param symmetry_angle: the angle of the symmetry line(s) + def symmetry_state_changed( + self, active, center, symmetry_type, symmetry_lines, angle): + """Event: symmetry state changed + + An argument value of None means that the state value has not changed, + allowing for granular updates but also making it necessary to add + None-checks if the values are to be used. + + :param bool active: whether symmetry is enabled or not + :param tuple center: the (x, y) coordinates of the symmetry center + :param int symmetry_type: the symmetry type + :param int symmetry_lines: new number of symmetry lines + :param float angle: the angle of the symmetry line(s) """ ## Current layer