Skip to content

Commit

Permalink
State pattern to make it easier to add brushes (#116 #330)
Browse files Browse the repository at this point in the history
  • Loading branch information
maoschanz committed Feb 21, 2021
1 parent 46bd0f7 commit 31c64a2
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 151 deletions.
3 changes: 3 additions & 0 deletions src/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ drawing_sources = [
'tools/classic_tools/tool_shape.py',
'tools/classic_tools/tool_text.py',

'tools/classic_tools/brushes/abstract_brush.py',
'tools/classic_tools/brushes/brush_simple.py',

'tools/selection_tools/abstract_select.py',
'tools/selection_tools/select_rect.py',
'tools/selection_tools/select_free.py',
Expand Down
62 changes: 62 additions & 0 deletions src/tools/classic_tools/brushes/abstract_brush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Licensed under GPL3 /~https://github.com/maoschanz/drawing/blob/master/LICENSE

import cairo

class AbstractBrush():
__gtype_name__ = 'AbstractBrush'

def __init__(self, brush_id, brush_tool, *args):
self._id = brush_id
self._tool = brush_tool

############################################################################

def draw_preview(self, operation, cairo_context):
cairo_context.set_operator(operation['operator'])
cairo_context.set_line_width(operation['line_width'])
cairo_context.new_path()
for pt in operation['path']:
cairo_context.line_to(pt['x'], pt['y'])
cairo_context.stroke()

def operation_on_mask(self, operation, original_context):
if operation['operator'] == cairo.Operator.CLEAR \
or operation['operator'] == cairo.Operator.SOURCE:
# When using CLEAR or SOURCE, we don't need to use a temporary
# surface, and actually we can't because using it as a mask would
# just erase the entire image.
original_context.set_operator(operation['operator'])
c = operation['rgba']
original_context.set_source_rgba(c.red, c.green, c.blue, c.alpha)
self.do_masked_brush_op(original_context, operation)
return

# Creation of a blank surface with a new context; each brush decides how
# to apply the options set by the user (`operation`), except for the
# operator which has to be "wrongly" set to SOURCE.
w = self._tool.get_surface().get_width()
h = self._tool.get_surface().get_height()
mask = cairo.ImageSurface(cairo.Format.ARGB32, w, h)
context2 = cairo.Context(mask)
context2.set_operator(cairo.Operator.SOURCE)
rgba = operation['rgba']
context2.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha)

self.do_masked_brush_op(context2, operation)

# Paint the surface onto the actual image with the chosen operator
original_context.set_operator(operation['operator'])
original_context.set_source_surface(mask)
original_context.paint()

############################################################################

def do_brush_operation(self, cairo_context, operation):
pass

def do_masked_brush_op(self, cairo_context, operation):
pass

############################################################################
################################################################################

133 changes: 133 additions & 0 deletions src/tools/classic_tools/brushes/brush_simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Licensed under GPL3 /~https://github.com/maoschanz/drawing/blob/master/LICENSE

import cairo, math
from gi.repository import Gdk
from .abstract_brush import AbstractBrush
from .utilities_paths import utilities_smooth_path

class BrushSimple(AbstractBrush):
__gtype_name__ = 'BrushSimple'

def draw_preview(self, operation, cairo_context):
cairo_context.set_line_cap(cairo.LineCap.ROUND)
cairo_context.set_line_join(cairo.LineJoin.ROUND)
rgba = operation['rgba']
cairo_context.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha)
super().draw_preview(operation, cairo_context)

############################################################################

def do_brush_operation(self, cairo_context, operation):
"""Brush with dynamic width, where the variation of width is drawn by a
succession of segments. If pressure is detected, the width is pressure-
sensitive, otherwise it's speed-sensitive (with a heavy ponderation to
make it less ugly)."""

if operation['is_preview']: # Previewing helps performance & debug
operation['line_width'] = int(operation['line_width'] / 2)
return self.draw_preview(operation, cairo_context)

if len(operation['path']) < 3:
# XXX minimum 3 points to get minimum 2 segments to avoid "list
# index out of range" errors when running the for loops
return

self.operation_on_mask(operation, cairo_context)

def do_masked_brush_op(self, cairo_context, operation):
cairo_context.set_line_cap(cairo.LineCap.ROUND)
cairo_context.set_line_join(cairo.LineJoin.ROUND)

# Build a raw path with lines between the points
cairo_context.new_path()
for pt in operation['path']:
cairo_context.line_to(pt['x'], pt['y'])
raw_path = cairo_context.copy_path()

# Smooth this raw path
cairo_context.new_path()
utilities_smooth_path(cairo_context, raw_path)
smoothed_path = cairo_context.copy_path()

# Build an array with all the widths for each segment
widths = self._build_widths(operation['path'], operation['line_width'])

# Run through the path to manually draw each segment with its width
i = 0
cairo_context.new_path()
for segment in smoothed_path:
i = i + 1
ok, future_x, future_y = self._future_point(segment)
if not ok:
cairo_context.move_to(future_x, future_y)
continue
current_x, current_y = cairo_context.get_current_point()
cairo_context.set_line_width(widths[i - 1])
self._add_segment(cairo_context, segment)
cairo_context.stroke()
cairo_context.move_to(future_x, future_y)

############################################################################
# Private methods ##########################################################

def _build_widths(self, manual_path, base_width):
"""Build an array of widths from the raw data, either using the value of
the pressure or based on the estimated speed of the movement."""
widths = []
dists = []
p2 = None
for pt in manual_path:
if pt['p'] is None:
# No data about pressure
if p2 is not None:
dists.append(self._get_dist(pt['x'], pt['y'], p2['x'], p2['y']))
else:
# There are data about pressure
if p2 is not None:
if p2['p'] == 0 or pt['p'] == 0:
seg_width = 0
else:
seg_width = (p2['p'] + pt['p']) / 2
# A segment whose 2 points have a 50% pressure shall have a
# width of "100%" of the base_width, so "base * mean * 2"
widths.append(base_width * seg_width * 2)
p2 = pt

# If nothing in widths, it has to be filled from dists
if len(widths) == 0:
min_dist = min(dists)
max_dist = max(dists)
temp_width = 0
for dist in dists:
new_width = 1 + int(base_width / max(1, 0.05 * dist))
if temp_width == 0:
temp_width = (new_width + base_width) / 2
else:
temp_width = (new_width + temp_width + temp_width) / 3
width = max(1, int(temp_width))
widths.append(width)

return widths

def _add_segment(self, cairo_context, pts):
if pts[0] == cairo.PathDataType.CURVE_TO:
cairo_context.curve_to(pts[1][0], pts[1][1], pts[1][2], pts[1][3], \
pts[1][4], pts[1][5])
elif pts[0] == cairo.PathDataType.LINE_TO:
cairo_context.line_to(pts[1][0], pts[1][1])

def _future_point(self, pts):
if pts[0] == cairo.PathDataType.CURVE_TO:
return True, pts[1][4], pts[1][5]
elif pts[0] == cairo.PathDataType.LINE_TO:
return True, pts[1][0], pts[1][1]
else: # all paths start with a cairo.PathDataType.MOVE_TO
return False, pts[1][0], pts[1][1]

def _get_dist(self, x1, y1, x2, y2):
dist2 = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)
return math.sqrt(dist2)

############################################################################
################################################################################

160 changes: 9 additions & 151 deletions src/tools/classic_tools/tool_brush.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import cairo, math
from gi.repository import Gdk
from .abstract_classic_tool import AbstractClassicTool
from .utilities_paths import utilities_smooth_path

from .brush_simple import BrushSimple

class ToolBrush(AbstractClassicTool):
__gtype_name__ = 'ToolBrush'
Expand All @@ -30,6 +30,10 @@ def __init__(self, window, **kwargs):
self._last_use_pressure = False
self.row.get_style_context().add_class('destructive-action')

self._brushes_dict = {
'simple': BrushSimple('simple', self),
}

self.add_tool_action_enum('brush-type', 'simple')
self.add_tool_action_enum('brush-dir', 'right')

Expand Down Expand Up @@ -102,6 +106,7 @@ def _get_pressure(self, event):
def build_operation(self):
operation = {
'tool_id': self.id,
'brush_id': 'simple',
'rgba': self.main_color,
'operator': self._operator,
'line_width': self.tool_width,
Expand All @@ -115,156 +120,9 @@ def do_tool_operation(self, operation):
if operation['path'] is None or len(operation['path']) < 1:
return
cairo_context = self.start_tool_operation(operation)
cairo_context.set_line_cap(cairo.LineCap.ROUND)
cairo_context.set_line_join(cairo.LineJoin.ROUND)
rgba = operation['rgba']
cairo_context.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha)

if operation['is_preview']: # Previewing helps performance & debug
operation['line_width'] = int(operation['line_width'] / 2)
return self.draw_preview(operation, cairo_context)
self.op_pressure(operation, cairo_context)

############################################################################

def draw_preview(self, operation, cairo_context):
cairo_context.set_operator(operation['operator'])
cairo_context.set_line_width(operation['line_width'])
cairo_context.new_path()
for pt in operation['path']:
cairo_context.line_to(pt['x'], pt['y'])
cairo_context.stroke()

############################################################################

def _build_widths(self, manual_path, base_width):
"""Build an array of widths from the raw data, either using the value of
the pressure or based on the estimated speed of the movement."""
widths = []
dists = []
p2 = None
for pt in manual_path:
if pt['p'] is None:
# No data about pressure
if p2 is not None:
dists.append(self._get_dist(pt['x'], pt['y'], p2['x'], p2['y']))
else:
# There are data about pressure
if p2 is not None:
if p2['p'] == 0 or pt['p'] == 0:
seg_width = 0
else:
seg_width = (p2['p'] + pt['p']) / 2
# A segment whose 2 points have a 50% pressure shall have a
# width of "100%" of the base_width, so "base * mean * 2"
widths.append(base_width * seg_width * 2)
p2 = pt

# If nothing in widths, it has to be filled from dists
if len(widths) == 0:
min_dist = min(dists)
max_dist = max(dists)
temp_width = 0
for dist in dists:
new_width = 1 + int(base_width / max(1, 0.05 * dist))
if temp_width == 0:
temp_width = (new_width + base_width) / 2
else:
temp_width = (new_width + temp_width + temp_width) / 3
width = max(1, int(temp_width))
widths.append(width)

return widths

def _add_segment(self, cairo_context, pts):
if pts[0] == cairo.PathDataType.CURVE_TO:
cairo_context.curve_to(pts[1][0], pts[1][1], pts[1][2], pts[1][3], \
pts[1][4], pts[1][5])
elif pts[0] == cairo.PathDataType.LINE_TO:
cairo_context.line_to(pts[1][0], pts[1][1])

def _future_point(self, pts):
if pts[0] == cairo.PathDataType.CURVE_TO:
return True, pts[1][4], pts[1][5]
elif pts[0] == cairo.PathDataType.LINE_TO:
return True, pts[1][0], pts[1][1]
else: # all paths start with a cairo.PathDataType.MOVE_TO
return False, pts[1][0], pts[1][1]

def _get_dist(self, x1, y1, x2, y2):
dist2 = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)
return math.sqrt(dist2)

############################################################################

def op_pressure(self, operation, cairo_context):
"""Brush with dynamic width, where the variation of width is drawn by a
succession of segments. If pressure is detected, the width is pressure-
sensitive, otherwise it's speed-sensitive (with a heavy ponderation to
make it less ugly)."""

if len(operation['path']) < 3:
# XXX minimum 3 points to get minimum 2 segments to avoid "list
# index out of range" errors when running the for loops
return

# Build an array with all the widths for each segment
widths = self._build_widths(operation['path'], operation['line_width'])

# Build a raw path with lines between the points
cairo_context.new_path()
for pt in operation['path']:
cairo_context.line_to(pt['x'], pt['y'])
raw_path = cairo_context.copy_path()

# Smooth this raw path
cairo_context.new_path()
utilities_smooth_path(cairo_context, raw_path)
smoothed_path = cairo_context.copy_path()

# Creation of a blank surface with a new context using the options set
# by the user, except the operator.
if operation['operator'] == cairo.Operator.CLEAR \
or operation['operator'] == cairo.Operator.SOURCE:
context2 = cairo_context
else:
w = self.get_surface().get_width()
h = self.get_surface().get_height()
mask = cairo.ImageSurface(cairo.Format.ARGB32, w, h)
context2 = cairo.Context(mask)
if operation['operator'] == cairo.Operator.CLEAR \
or operation['operator'] == cairo.Operator.SOURCE:
context2.set_operator(operation['operator'])
else:
context2.set_operator(cairo.Operator.SOURCE)
rgba = operation['rgba']
context2.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha)

context2.set_line_cap(cairo.LineCap.ROUND)
context2.set_line_join(cairo.LineJoin.ROUND)
# Run through the path to manually draw each segment with its width
i = 0
context2.new_path()
for segment in smoothed_path:
i = i + 1
ok, future_x, future_y = self._future_point(segment)
if not ok:
context2.move_to(future_x, future_y)
continue
current_x, current_y = context2.get_current_point()
context2.set_line_width(widths[i - 1])
self._add_segment(context2, segment)
context2.stroke()
context2.move_to(future_x, future_y)

if operation['operator'] == cairo.Operator.CLEAR \
or operation['operator'] == cairo.Operator.SOURCE:
return

# Paint the surface onto the actual image with the chosen operator
cairo_context.set_operator(operation['operator'])
cairo_context.set_source_surface(mask)
cairo_context.paint()
active_brush = self._brushes_dict[operation['brush_id']]
active_brush.do_brush_operation(cairo_context, operation)

############################################################################
################################################################################
Expand Down

0 comments on commit 31c64a2

Please sign in to comment.