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