1# This file is part of MyPaint.
2# Copyright (C) 2014 by Andrew Chadwick <a.t.chadwick@gmail.com>
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8
9"""Graphical rendering helpers (splines, alpha checks, brush preview)
10
11See also: gui.style
12
13"""
14
15## Imports
16
17from __future__ import division, print_function
18import logging
19import math
20
21from lib.brush import Brush, BrushInfo
22import lib.tiledsurface
23from lib.pixbufsurface import render_as_pixbuf
24from lib.helpers import clamp
25import gui.style
26import lib.color
27from lib.pycompat import xrange
28
29import numpy
30import cairo
31from lib.gibindings import GdkPixbuf
32from lib.gibindings import Gdk
33from lib.gibindings import Gtk
34
35logger = logging.getLogger(__name__)
36
37## Module constants
38
39_BRUSH_PREVIEW_POINTS = [
40    # px,  py,  press, xtilt, ytilt # px, py,   press, xtilt, ytilt
41    (0.00, 0.00, 0.00, 0.00, 0.00), (1.00, 0.05, 0.00, -0.06, 0.05),
42    (0.10, 0.10, 0.20, 0.10, 0.05), (0.90, 0.15, 0.90, -0.05, 0.05),
43    (0.11, 0.30, 0.90, 0.08, 0.05), (0.86, 0.35, 0.90, -0.04, 0.05),
44    (0.13, 0.50, 0.90, 0.06, 0.05), (0.84, 0.55, 0.90, -0.03, 0.05),
45    (0.17, 0.70, 0.90, 0.04, 0.05), (0.83, 0.75, 0.90, -0.02, 0.05),
46    (0.25, 0.90, 0.20, 0.02, 0.00), (0.81, 0.95, 0.00, 0.00, 0.00),
47    (0.41, 0.95, 0.00, 0.00, 0.00), (0.80, 1.00, 0.00, 0.00, 0.00),
48]
49
50
51## Drawing functions
52
53def spline_4p(t, p_1, p0, p1, p2):
54    """Interpolated point using a Catmull-Rom spline
55
56    :param float t: Time parameter, between 0.0 and 1.0
57    :param numpy.array p_1: Point p[-1]
58    :param numpy.array p0: Point p[0]
59    :param numpy.array p1: Point p[1]
60    :param numpy.array p2: Point p[2]
61    :returns: Interpolated point, between p0 and p1
62    :rtype: numpy.array
63
64    Used for a succession of points, this function makes smooth curves
65    passing through all specified points, other than the first and last.
66    For each pair of points, and their immediate predecessor and
67    successor points, the `t` parameter should be stepped incrementally
68    from 0 (for point p0) to 1 (for point p1).  See also:
69
70    * `spline_iter()`
71    * http://en.wikipedia.org/wiki/Cubic_Hermite_spline
72    * http://stackoverflow.com/questions/1251438
73    """
74    return (
75        t*((2-t)*t - 1) * p_1 +
76        (t*t*(3*t - 5) + 2) * p0 +
77        t*((4 - 3*t)*t + 1) * p1 +
78        (t-1)*t*t * p2
79    ) / 2
80
81
82def spline_iter(tuples, double_first=True, double_last=True):
83    """Converts an list of control point tuples to interpolatable numpy.arrays
84
85    :param list tuples: Sequence of tuples of floats
86    :param bool double_first: Repeat 1st point, putting it in the result
87    :param bool double_last: Repeat last point, putting it in the result
88    :returns: Iterator producing (p-1, p0, p1, p2)
89
90    The resulting sequence of 4-tuples is intended to be fed into
91    spline_4p().  The start and end points are therefore normally
92    doubled, producing a curve that passes through them, along a vector
93    aimed at the second or penultimate point respectively.
94
95    """
96    cint = [None, None, None, None]
97    if double_first:
98        cint[0:3] = cint[1:4]
99        cint[3] = numpy.array(tuples[0])
100    for ctrlpt in tuples:
101        cint[0:3] = cint[1:4]
102        cint[3] = numpy.array(ctrlpt)
103        if not any((a is None) for a in cint):
104            yield cint
105    if double_last:
106        cint[0:3] = cint[1:4]
107        cint[3] = numpy.array(tuples[-1])
108        yield cint
109
110
111def _variable_pressure_scribble(w, h, tmult):
112    points = _BRUSH_PREVIEW_POINTS
113    px, py, press, xtilt, ytilt = points[0]
114    yield (10, px*w, py*h, 0.0, xtilt, ytilt)
115    event_dtime = 0.005
116    point_time = 0.1
117    for p_1, p0, p1, p2 in spline_iter(points, True, True):
118        dt = 0.0
119        while dt < point_time:
120            t = dt/point_time
121            px, py, press, xtilt, ytilt = spline_4p(t, p_1, p0, p1, p2)
122            yield (event_dtime, px*w, py*h, press, xtilt, ytilt)
123            dt += event_dtime
124    px, py, press, xtilt, ytilt = points[-1]
125    yield (10, px*w, py*h, 0.0, xtilt, ytilt)
126
127
128def render_brush_preview_pixbuf(brushinfo, max_edge_tiles=4):
129    """Renders brush preview images
130
131    :param BrushInfo brushinfo: settings to render
132    :param int max_edge_tiles: Use at most this many tiles along an edge
133    :returns: Preview image, at 128x128 pixels
134    :rtype: GdkPixbuf
135
136    This generates the preview image (128px icon) used for brushes which
137    don't have saved ones. These include brushes picked from .ORA files
138    where the parent_brush_name doesn't correspond to a brush in the
139    user's MyPaint brushes - they're used as the default, and for the
140    Auto button in the Brush Icon editor.
141
142    Brushstrokes are inherently unpredictable in size, so the allowable
143    area is grown until the brush fits or until the rendering becomes
144    too big. `max_edge_tiles` limits this growth.
145    """
146    assert max_edge_tiles >= 1
147    brushinfo = brushinfo.clone()  # avoid capturing a ref
148    brush = Brush(brushinfo)
149    surface = lib.tiledsurface.Surface()
150    n = lib.tiledsurface.N
151    for size_in_tiles in range(1, max_edge_tiles):
152        width = n * size_in_tiles
153        height = n * size_in_tiles
154        surface.clear()
155        fg, spiral = _brush_preview_bg_fg(surface, size_in_tiles, brushinfo)
156        brushinfo.set_color_rgb(fg)
157        brush.reset()
158        # Curve
159        shape = _variable_pressure_scribble(width, height, size_in_tiles)
160        surface.begin_atomic()
161        for dt, x, y, p, xt, yt in shape:
162            brush.stroke_to(
163                surface.backend, x, y, p, xt, yt, dt, 1.0, 0.0, 0.0)
164        surface.end_atomic()
165        # Check rendered size
166        tposs = surface.tiledict.keys()
167
168        outside = min({tx for tx, ty in tposs}) < 0
169        outside = outside or (min({ty for tx, ty in tposs}) < 0)
170        outside = outside or (max({tx for tx, ty in tposs}) >= size_in_tiles)
171        outside = outside or (max({ty for tx, ty in tposs}) >= size_in_tiles)
172
173        if not outside:
174            break
175    # Convert to pixbuf at the right scale
176    rect = (0, 0, width, height)
177    pixbuf = render_as_pixbuf(surface, *rect, alpha=True)
178    if max(width, height) != 128:
179        interp = (GdkPixbuf.InterpType.NEAREST if max(width, height) < 128
180                  else GdkPixbuf.InterpType.BILINEAR)
181        pixbuf = pixbuf.scale_simple(128, 128, interp)
182    # Composite over a checquered bg via Cairo: shows erases
183    size = gui.style.ALPHA_CHECK_SIZE
184    nchecks = int(128 // size)
185    cairo_surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, 128, 128)
186    cr = cairo.Context(cairo_surf)
187    render_checks(cr, size, nchecks)
188    Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0)
189    cr.paint()
190    cairo_surf.flush()
191    return Gdk.pixbuf_get_from_surface(cairo_surf, 0, 0, 128, 128)
192
193
194def _brush_preview_bg_fg(surface, size_in_tiles, brushinfo):
195    """Render the background for brush previews, return paint color"""
196    # The background color represents the overall nature of the brush
197    col1 = (0.85, 0.85, 0.80)  # Boring grey, with a hint of paper-yellow
198    col2 = (0.80, 0.80, 0.80)  # Grey, but will appear blueish in contrast
199    fgcol = (0.05, 0.15, 0.20)  # Hint of color shows off HSV varier brushes
200    spiral = False
201    n = lib.tiledsurface.N
202    fx = [
203        (
204            "eraser",  # pink=rubber=eraser; red=danger
205            (0.8, 0.7, 0.7),  # pink/red tones: pencil eraser/danger
206            (0.75, 0.60, 0.60),
207            False, fgcol
208        ),
209        (
210            "colorize",
211            (0.8, 0.8, 0.8),  # orange on gray
212            (0.6, 0.6, 0.6),
213            False, (0.6, 0.2, 0.0)
214        ),
215        (
216            "smudge",  # blue=water=wet, with some contrast
217            (0.85, 0.85, 0.80),  # same as the regular paper color
218            (0.60, 0.60, 0.70),  # bluer (water, wet); more contrast
219            True, fgcol
220        ),
221    ]
222    for cname, c1, c2, c_spiral, c_fg, in fx:
223        if brushinfo.has_large_base_value(cname):
224            col1 = c1
225            col2 = c2
226            fgcol = c_fg
227            spiral = c_spiral
228            break
229
230    never_smudger = (brushinfo.has_small_base_value("smudge") and
231                     brushinfo.has_only_base_value("smudge"))
232    colorizer = brushinfo.has_large_base_value("colorize")
233
234    if never_smudger and not colorizer:
235        col2 = col1
236
237    a = 1 << 15
238    col1_fix15 = [c*a for c in col1] + [a]
239    col2_fix15 = [c*a for c in col2] + [a]
240    for ty in range(0, size_in_tiles):
241        tx_thres = max(0, size_in_tiles - ty - 1)
242        for tx in range(0, size_in_tiles):
243            topcol = col1_fix15
244            botcol = col1_fix15
245            if tx > tx_thres:
246                topcol = col2_fix15
247            if tx >= tx_thres:
248                botcol = col2_fix15
249            with surface.tile_request(tx, ty, readonly=False) as dst:
250                if topcol == botcol:
251                    dst[:] = topcol
252                else:
253                    for i in range(n):
254                        dst[0:n-i, i, ...] = topcol
255                        dst[n-i:n, i, ...] = botcol
256    return fgcol, spiral
257
258
259def render_checks(cr, size, nchecks):
260    """Render a checquerboard pattern to a cairo surface"""
261    cr.set_source_rgb(*gui.style.ALPHA_CHECK_COLOR_1)
262    cr.paint()
263    cr.set_source_rgb(*gui.style.ALPHA_CHECK_COLOR_2)
264    for i in xrange(0, nchecks):
265        for j in xrange(0, nchecks):
266            if (i+j) % 2 == 0:
267                continue
268            cr.rectangle(i*size, j*size, size, size)
269            cr.fill()
270
271
272def load_symbolic_icon(icon_name, size, fg=None, success=None,
273                       warning=None, error=None, outline=None):
274    """More Pythonic wrapper for gtk_icon_info_load_symbolic() etc.
275
276    :param str icon_name: Name of the symbolic icon to render
277    :param int size: Pixel size to render at
278    :param tuple fg: foreground color (rgba tuple, values in [0..1])
279    :param tuple success: success color (rgba tuple, values in [0..1])
280    :param tuple warning: warning color (rgba tuple, values in [0..1])
281    :param tuple error: error color (rgba tuple, values in [0..1])
282    :param tuple outline: outline color (rgba tuple, values in [0..1])
283    :returns: The rendered symbolic icon
284    :rtype: GdkPixbuf.Pixbuf
285
286    If the outline color is specified, a single-pixel outline is faked
287    for the icon. Outlined renderings require a size 2 pixels larger
288    than non-outlined if the central icon is to be of the same size.
289
290    The returned value should be cached somewhere.
291
292    """
293    theme = Gtk.IconTheme.get_default()
294    if outline is not None:
295        size -= 2
296    info = theme.lookup_icon(icon_name, size, Gtk.IconLookupFlags(0))
297
298    def rgba_or_none(tup):
299        return (tup is not None) and Gdk.RGBA(*tup) or None
300
301    icon_pixbuf, was_symbolic = info.load_symbolic(
302        fg=rgba_or_none(fg),
303        success_color=rgba_or_none(success),
304        warning_color=rgba_or_none(warning),
305        error_color=rgba_or_none(error),
306    )
307    assert was_symbolic
308    if outline is None:
309        return icon_pixbuf
310
311    result = GdkPixbuf.Pixbuf.new(
312        GdkPixbuf.Colorspace.RGB, True, 8,
313        size+2, size+2,
314    )
315    result.fill(0x00000000)
316    outline_rgba = list(outline)
317    outline_rgba[3] /= 3.0
318    outline_rgba = Gdk.RGBA(*outline_rgba)
319    outline_stamp, was_symbolic = info.load_symbolic(
320        fg=outline_rgba,
321        success_color=outline_rgba,
322        warning_color=outline_rgba,
323        error_color=outline_rgba,
324    )
325    w = outline_stamp.get_width()
326    h = outline_stamp.get_height()
327    assert was_symbolic
328    offsets = [
329        (-1, -1), (0, -1), (1, -1),
330        (-1, 0),          (1, 0),   # noqa: E241 (it's clearer)
331        (-1, 1), (0, 1), (1, 1),
332    ]
333    for dx, dy in offsets:
334        outline_stamp.composite(
335            result,
336            dx+1, dy+1, w, h,
337            dx+1, dy+1, 1, 1,
338            GdkPixbuf.InterpType.NEAREST, 255,
339        )
340    icon_pixbuf.composite(
341        result,
342        1, 1, w, h,
343        1, 1, 1, 1,
344        GdkPixbuf.InterpType.NEAREST, 255,
345    )
346    return result
347
348
349def render_round_floating_button(cr, x, y, color, pixbuf, z=2,
350                                 radius=gui.style.FLOATING_BUTTON_RADIUS):
351    """Draw a round floating button with a standard size.
352
353    :param cairo.Context cr: Context in which to draw.
354    :param float x: X coordinate of the center pixel.
355    :param float y: Y coordinate of the center pixel.
356    :param lib.color.UIColor color: Color for the button base.
357    :param GdkPixbuf.Pixbuf pixbuf: Icon to render.
358    :param int z: Simulated height of the button above the canvas.
359    :param float radius: Button radius, in pixels.
360
361    These are used within certain overlays tightly associated with
362    particular interaction modes for manipulating things on the canvas.
363
364    """
365    x = round(float(x))
366    y = round(float(y))
367    render_round_floating_color_chip(cr, x, y, color, radius=radius, z=z)
368    cr.save()
369    w = pixbuf.get_width()
370    h = pixbuf.get_height()
371    x -= w/2
372    y -= h/2
373    Gdk.cairo_set_source_pixbuf(cr, pixbuf, x, y)
374    cr.rectangle(x, y, w, h)
375    cr.clip()
376    cr.paint()
377    cr.restore()
378
379
380def _get_paint_chip_highlight(color):
381    """Paint chip highlight edge color"""
382    highlight = lib.color.HCYColor(color=color)
383    ky = gui.style.PAINT_CHIP_HIGHLIGHT_HCY_Y_MULT
384    kc = gui.style.PAINT_CHIP_HIGHLIGHT_HCY_C_MULT
385    highlight.y = clamp(highlight.y * ky, 0, 1)
386    highlight.c = clamp(highlight.c * kc, 0, 1)
387    return highlight
388
389
390def _get_paint_chip_shadow(color):
391    """Paint chip shadow edge color"""
392    shadow = lib.color.HCYColor(color=color)
393    ky = gui.style.PAINT_CHIP_SHADOW_HCY_Y_MULT
394    kc = gui.style.PAINT_CHIP_SHADOW_HCY_C_MULT
395    shadow.y = clamp(shadow.y * ky, 0, 1)
396    shadow.c = clamp(shadow.c * kc, 0, 1)
397    return shadow
398
399
400def render_round_floating_color_chip(cr, x, y, color, radius, z=2):
401    """Draw a round color chip with a slight drop shadow
402
403    :param cairo.Context cr: Context in which to draw.
404    :param float x: X coordinate of the center pixel.
405    :param float y: Y coordinate of the center pixel.
406    :param lib.color.UIColor color: Color for the chip.
407    :param float radius: Circle radius, in pixels.
408    :param int z: Simulated height of the object above the canvas.
409
410    Currently used for accept/dismiss/delete buttons and control points
411    on the painting canvas, in certain modes.
412
413    The button's style is similar to that used for the paint chips in
414    the dockable palette panel. As used here with drop shadows to
415    indicate that the blob can be interacted with, the style is similar
416    to Google's Material Design approach. This style adds a subtle edge
417    highlight in a brighter variant of "color", which seems to help
418    address adjacent color interactions.
419
420    """
421    x = round(float(x))
422    y = round(float(y))
423    radius = round(radius)
424
425    cr.save()
426    cr.set_dash([], 0)
427    cr.set_line_width(0)
428
429    base_col = lib.color.RGBColor(color=color)
430    hi_col = _get_paint_chip_highlight(base_col)
431
432    cr.arc(x, y, radius+0, 0, 2*math.pi)
433    cr.set_line_width(2)
434    render_drop_shadow(cr, z=z)
435
436    cr.set_source_rgb(*base_col.get_rgb())
437    cr.fill_preserve()
438    cr.clip_preserve()
439
440    cr.set_source_rgb(*hi_col.get_rgb())
441    cr.stroke()
442
443    cr.restore()
444
445
446def render_drop_shadow(cr, z=2, line_width=None):
447    """Draws a drop shadow for the current path.
448
449    :param int z: Simulated height of the object above the canvas.
450    :param float line_width: Override width of the line to shadow.
451
452    This function assumes that the object will be drawn immediately
453    afterwards using the current path, so the current path and transform
454    are preserved. The line width will be inferred automatically from
455    the current path if it is not specified.
456
457    These shadows are suitable for lines of a single brightish color
458    drawn over them. The combined style indicates that the object can be
459    moved or clicked.
460
461    """
462    if line_width is None:
463        line_width = cr.get_line_width()
464    path = cr.copy_path()
465    cr.save()
466    dx = gui.style.DROP_SHADOW_X_OFFSET * z
467    dy = gui.style.DROP_SHADOW_Y_OFFSET * z
468    cr.translate(dx, dy)
469    cr.new_path()
470    cr.append_path(path)
471    steps = int(math.ceil(gui.style.DROP_SHADOW_BLUR))
472    alpha = gui.style.DROP_SHADOW_ALPHA / steps
473    for i in reversed(range(steps)):
474        cr.set_source_rgba(0.0, 0.0, 0.0, alpha)
475        cr.set_line_width(line_width + 2*i)
476        cr.stroke_preserve()
477        alpha += alpha/2
478    cr.translate(-dx, -dy)
479    cr.new_path()
480    cr.append_path(path)
481    cr.restore()
482
483
484def get_drop_shadow_offsets(line_width, z=2):
485    """Get how much extra space is needed to draw the drop shadow.
486
487    :param float line_width: Width of the line to shadow.
488    :param int z: Simulated height of the object above the canvas.
489    :returns: Offsets: (offs_left, offs_top, offs_right, offs_bottom)
490    :rtype: tuple
491
492    The offsets returned can be added to redraw bboxes, and are always
493    positive. They reflect how much extra space is required around the
494    bounding box for a line of the given width by the shadow rendered by
495    render_drop_shadow().
496
497    """
498    dx = math.ceil(gui.style.DROP_SHADOW_X_OFFSET * z)
499    dy = math.ceil(gui.style.DROP_SHADOW_Y_OFFSET * z)
500    max_i = int(math.ceil(gui.style.DROP_SHADOW_BLUR)) - 1
501    max_line_width = line_width + 2*max_i
502    slack = 1
503    return tuple(int(max(0, n)) for n in [
504        -dx + max_line_width + slack,
505        -dy + max_line_width + slack,
506        dx + max_line_width + slack,
507        dy + max_line_width + slack,
508    ])
509
510
511## Test code
512
513if __name__ == '__main__':
514    logging.basicConfig(level=logging.DEBUG)
515    import sys
516    import lib.pixbuf
517    for myb_file in sys.argv[1:]:
518        if not myb_file.lower().endswith(".myb"):
519            logger.warning("Ignored %r: not a .myb file", myb_file)
520            continue
521        with open(myb_file, 'r') as myb_fp:
522            myb_json = myb_fp.read()
523        myb_brushinfo = BrushInfo(myb_json)
524        myb_pixbuf = render_brush_preview_pixbuf(myb_brushinfo)
525        if myb_pixbuf is not None:
526            myb_basename = myb_file[:-4]
527            png_file = "%s_autopreview.png" % (myb_file,)
528            logger.info("Saving to %r...", png_file)
529            lib.pixbuf.save(myb_pixbuf, png_file, "png")
530