1#!/usr/local/bin/python 2 3# Draw Spyrographs, Epitrochoids, and Lissajous curves with interactive feedback. 4# 5# This program is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation; either version 3 of the License, or 8# (at your option) any later version. 9# 10# This program is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with this program. If not, see <https://www.gnu.org/licenses/>. 17 18from gimpshelf import shelf 19from gimpenums import * 20import gimp 21import gimpplugin 22import gimpui 23import gobject 24import gtk 25gdk = gtk.gdk 26 27from math import pi, sin, cos, atan, atan2, fmod, radians, sqrt 28import gettext 29import fractions 30import time 31 32 33# i18n 34t = gettext.translation("gimp20-python", gimp.locale_directory, fallback=True) 35_ = t.ugettext 36 37def N_(message): 38 return message 39 40 41pdb = gimp.pdb 42 43two_pi, half_pi = 2 * pi, pi / 2 44layer_name = _("Spyro Layer") 45path_name = _("Spyro Path") 46 47# "Enums" 48GEAR_NOTATION, TOY_KIT_NOTATION, VISUAL_NOTATION = range(3) # Pattern notations 49 50# Mapping of pattern notation to the corresponding tab in the pattern notation notebook. 51pattern_notation_page = {} 52 53# Save options of the dialog 54SAVE_AS_NEW_LAYER, SAVE_BY_REDRAW, SAVE_AS_PATH = range(3) 55save_options = [ 56 _("Save\nas New Layer"), 57 _("Redraw on\nActive layer"), 58 _("Save\nas Path") 59] 60 61ring_teeth = [96, 144, 105, 150] 62 63# Moving gear. Each gear is a pair of (#teeth, #holes) 64# Hole #1 is closest to the edge of the wheel. 65# The last hole is closest to the center. 66wheel = [ 67 (24, 5), (30, 8), (32, 9), (36, 11), (40, 13), (42, 14), (45, 16), 68 (48, 17), (50, 18), (52, 19), (56, 21), (60, 23), (63, 25), (64, 25), 69 (72, 29), (75, 31), (80, 33), (84, 35) 70] 71wheel_teeth = [wh[0] for wh in wheel] 72 73 74def lcm(a, b): 75 """ Least common multiplier """ 76 return a * b // fractions.gcd(a, b) 77 78 79### Shapes 80 81 82class CanRotateShape: 83 pass 84 85 86class Shape: 87 def configure(self, img, pp, cp): 88 self.image, self.pp, self.cp = img, pp, cp 89 90 def can_equal_w_h(self): 91 return True 92 93 def has_sides(self): 94 return isinstance(self, SidedShape) 95 96 def can_rotate(self): 97 return isinstance(self, CanRotateShape) 98 99 def can_morph(self): 100 return self.has_sides() 101 102 103class CircleShape(Shape): 104 name = _("Circle") 105 106 def get_center_of_moving_gear(self, oangle, dist=None): 107 """ 108 :return: x,y - position where the center of the moving gear should be, 109 after going over oangle/two_pi of a full cycle over the outer gear. 110 """ 111 cp = self.cp 112 if dist is None: 113 dist = cp.moving_gear_radius 114 115 return (cp.x_center + (cp.x_half_size - dist) * cos(oangle), 116 cp.y_center + (cp.y_half_size - dist) * sin(oangle)) 117 118 119class SidedShape(CanRotateShape, Shape): 120 121 def configure(self, img, pp, cp): 122 Shape.configure(self, img, pp, cp) 123 self.angle_of_each_side = two_pi / pp.sides 124 self.half_angle = self.angle_of_each_side / 2.0 125 self.cos_half_angle = cos(self.half_angle) 126 127 def get_center_of_moving_gear(self, oangle, dist=None): 128 if dist is None: 129 dist = self.cp.moving_gear_radius 130 shape_factor = self.get_shape_factor(oangle) 131 return ( 132 self.cp.x_center + 133 (self.cp.x_half_size - dist) * shape_factor * cos(oangle), 134 self.cp.y_center + 135 (self.cp.y_half_size - dist) * shape_factor * sin(oangle) 136 ) 137 138 139class PolygonShape(SidedShape): 140 name = _("Polygon-Star") 141 142 def get_shape_factor(self, oangle): 143 oangle_mod = fmod(oangle + self.cp.shape_rotation_radians, self.angle_of_each_side) 144 if oangle_mod > self.half_angle: 145 oangle_mod = self.angle_of_each_side - oangle_mod 146 147 # When oangle_mod = 0, the shape_factor will be cos(half_angle)) - which is the minimal shape_factor. 148 # When oangle_mod is near the half_angle, the shape_factor will near 1. 149 shape_factor = self.cos_half_angle / cos(oangle_mod) 150 shape_factor -= self.pp.morph * (1 - shape_factor) * (1 + (self.pp.sides - 3) * 2) 151 return shape_factor 152 153 154class SineShape(SidedShape): 155 # Sine wave on a circle ring. 156 name = _("Sine") 157 158 def get_shape_factor(self, oangle): 159 oangle_mod = fmod(oangle + self.cp.shape_rotation_radians, self.angle_of_each_side) 160 oangle_stretched = oangle_mod * self.pp.sides 161 return 1 - self.pp.morph * (cos(oangle_stretched) + 1) 162 163 164class BumpShape(SidedShape): 165 # Semi-circles, based on a polygon 166 name = _("Bumps") 167 168 def get_shape_factor(self, oangle): 169 oangle_mod = fmod(oangle + self.cp.shape_rotation_radians, self.angle_of_each_side) 170 # Stretch back to angle between 0 and pi 171 oangle_stretched = oangle_mod/2.0 * self.pp.sides 172 173 # Compute factor for polygon. 174 poly_angle = oangle_mod 175 if poly_angle > self.half_angle: 176 poly_angle = self.angle_of_each_side - poly_angle 177 # When poly_oangle = 0, the shape_factor will be cos(half_angle)) - the minimal shape_factor. 178 # When poly_angle is near the half_angle, the shape_factor will near 1. 179 polygon_factor = self.cos_half_angle / cos(poly_angle) 180 181 # Bump 182 return polygon_factor - self.pp.morph * (1 - abs(cos(oangle_stretched))) 183 184 185class ShapePart(object): 186 def set_bounds(self, start, end): 187 self.bound_start, self.bound_end = start, end 188 self.bound_diff = self.bound_end - self.bound_start 189 190 191class StraightPart(ShapePart): 192 193 def __init__(self, teeth, perp_direction, x1, y1, x2, y2): 194 self.teeth, self.perp_direction = max(teeth, 1), perp_direction 195 self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2 196 self.x_diff = self.x2 - self.x1 197 self.y_diff = self.y2 - self.y1 198 199 angle = atan2(self.y_diff, self.x_diff) # - shape_rotation_radians 200 perp_angle = angle + perp_direction * half_pi 201 self.sin_angle = sin(perp_angle) 202 self.cos_angle = cos(perp_angle) 203 204 def perpendicular_at_oangle(self, oangle, perp_distance): 205 factor = (oangle - self.bound_start) / self.bound_diff 206 return (self.x1 + factor * self.x_diff + perp_distance * self.cos_angle, 207 self.y1 + factor * self.y_diff + perp_distance * self.sin_angle) 208 209 210class RoundPart(ShapePart): 211 212 def __init__(self, teeth, x, y, start_angle, end_angle): 213 self.teeth = max(teeth, 1) 214 self.start_angle, self.end_angle = start_angle, end_angle 215 self.x, self.y = x, y 216 217 self.diff_angle = self.end_angle - self.start_angle 218 219 def perpendicular_at_oangle(self, oangle, perp_distance): 220 angle = ( 221 self.start_angle + 222 self.diff_angle * (oangle - self.bound_start) / self.bound_diff 223 ) 224 return (self.x + perp_distance * cos(angle), 225 self.y + perp_distance * sin(angle)) 226 227 228class ShapeParts(list): 229 """ A list of shape parts. """ 230 231 def __init__(self): 232 list.__init__(self) 233 self.total_teeth = 0 234 235 def finish(self): 236 for part in self: 237 self.total_teeth += part.teeth 238 teeth = 0 239 bound_end = 0.0 240 for part in self: 241 bound_start = bound_end 242 teeth += part.teeth 243 bound_end = teeth/float(self.total_teeth) * two_pi 244 part.set_bounds(bound_start, bound_end) 245 246 def perpendicular_at_oangle(self, oangle, perp_distance): 247 for part in self: 248 if oangle <= part.bound_end: 249 return part.perpendicular_at_oangle(oangle, perp_distance) 250 251 # We shouldn't reach here 252 return 0.0, 0.0 253 254 255class AbstractShapeFromParts(Shape): 256 def __init__(self): 257 self.parts = None 258 259 def get_center_of_moving_gear(self, oangle, dist=None): 260 """ 261 :param oangle: an angle in radians, between 0 and 2*pi 262 :return: x,y - position where the center of the moving gear should be, 263 after going over oangle/two_pi of a full cycle over the outer gear. 264 """ 265 if dist is None: 266 dist = self.cp.moving_gear_radius 267 return self.parts.perpendicular_at_oangle(oangle, dist) 268 269 270class RackShape(CanRotateShape, AbstractShapeFromParts): 271 name = _("Rack") 272 273 def configure(self, img, pp, cp): 274 Shape.configure(self, img, pp, cp) 275 276 round_teeth = 12 277 side_teeth = (cp.fixed_gear_teeth - 2 * round_teeth) / 2 278 279 # Determine start and end points of rack. 280 281 cos_rot = cos(cp.shape_rotation_radians) 282 sin_rot = sin(cp.shape_rotation_radians) 283 284 x_size = cp.x2 - cp.x1 - cp.moving_gear_radius * 4 285 y_size = cp.y2 - cp.y1 - cp.moving_gear_radius * 4 286 287 size = ((x_size * cos_rot)**2 + (y_size * sin_rot)**2) ** 0.5 288 289 x1 = cp.x_center - size/2.0 * cos_rot 290 y1 = cp.y_center - size/2.0 * sin_rot 291 x2 = cp.x_center + size/2.0 * cos_rot 292 y2 = cp.y_center + size/2.0 * sin_rot 293 294 # Build shape from shape parts. 295 self.parts = ShapeParts() 296 self.parts.append(StraightPart(side_teeth, -1, x2, y2, x1, y1)) 297 self.parts.append( 298 RoundPart( 299 round_teeth, x1, y1, 300 half_pi + cp.shape_rotation_radians, 301 3 * half_pi + cp.shape_rotation_radians 302 ) 303 ) 304 self.parts.append(StraightPart(side_teeth, -1, x1, y1, x2, y2)) 305 self.parts.append( 306 RoundPart( 307 round_teeth, x2, y2, 308 3 * half_pi + cp.shape_rotation_radians, 309 5 * half_pi + cp.shape_rotation_radians) 310 ) 311 self.parts.finish() 312 313 314class FrameShape(AbstractShapeFromParts): 315 name = _("Frame") 316 317 def configure(self, img, pp, cp): 318 Shape.configure(self, img, pp, cp) 319 320 x1, x2 = cp.x1 + cp.moving_gear_radius, cp.x2 - cp.moving_gear_radius 321 y1, y2 = cp.y1 + cp.moving_gear_radius, cp.y2 - cp.moving_gear_radius 322 x_diff, y_diff = abs(x2 - x1), abs(y2 - y1) 323 324 # Build shape from shape parts. 325 self.parts = ShapeParts() 326 self.parts.append(StraightPart(x_diff, 1, x2, cp.y2, x1, cp.y2)) 327 self.parts.append(StraightPart(y_diff, 1, cp.x1, y2, cp.x1, y1)) 328 self.parts.append(StraightPart(x_diff, 1, x1, cp.y1, x2, cp.y1)) 329 self.parts.append(StraightPart(y_diff, 1, cp.x2, y1, cp.x2, y2)) 330 self.parts.finish() 331 332 333class SelectionToPath: 334 """ Converts a selection to a path """ 335 336 def __init__(self, image): 337 self.image = image 338 339 # Compute hash of selection, so we can detect when it was modified. 340 self.last_selection_hash = self.compute_selection_hash() 341 342 self.convert_selection_to_path() 343 344 def convert_selection_to_path(self): 345 346 if pdb.gimp_selection_is_empty(self.image): 347 selection_was_empty = True 348 pdb.gimp_selection_all(self.image) 349 else: 350 selection_was_empty = False 351 352 pdb.plug_in_sel2path(self.image, self.image.active_layer) 353 354 self.path = self.image.vectors[0] 355 356 self.num_strokes, self.stroke_ids = pdb.gimp_vectors_get_strokes(self.path) 357 self.stroke_ids = list(self.stroke_ids) 358 359 # A path may contain several strokes. If so lets throw away a stroke that 360 # simply describes the borders of the image, if one exists. 361 if self.num_strokes > 1: 362 # Lets compute what a stroke of the image borders should look like. 363 w, h = float(self.image.width), float(self.image.height) 364 frame_strokes = [0.0] * 6 + [0.0, h] * 3 + [w, h] * 3 + [w, 0.0] * 3 365 366 for stroke in range(self.num_strokes): 367 strokes = self.path.strokes[stroke].points[0] 368 if strokes == frame_strokes: 369 del self.stroke_ids[stroke] 370 self.num_strokes -= 1 371 break 372 373 self.set_current_stroke(0) 374 375 if selection_was_empty: 376 # Restore empty selection if it was empty. 377 pdb.gimp_selection_none(self.image) 378 379 def compute_selection_hash(self): 380 px = self.image.selection.get_pixel_rgn(0, 0, self.image.width, self.image.height) 381 return px[0:self.image.width, 0:self.image.height].__hash__() 382 383 def regenerate_path_if_selection_changed(self): 384 current_selection_hash = self.compute_selection_hash() 385 if self.last_selection_hash != current_selection_hash: 386 self.last_selection_hash = current_selection_hash 387 self.convert_selection_to_path() 388 389 def get_num_strokes(self): 390 return self.num_strokes 391 392 def set_current_stroke(self, stroke_id=0): 393 # Compute path length. 394 self.path_length = pdb.gimp_vectors_stroke_get_length(self.path, self.stroke_ids[stroke_id], 1.0) 395 self.current_stroke = stroke_id 396 397 def point_at_angle(self, oangle): 398 oangle_mod = fmod(oangle, two_pi) 399 dist = self.path_length * oangle_mod / two_pi 400 return pdb.gimp_vectors_stroke_get_point_at_dist(self.path, self.stroke_ids[self.current_stroke], dist, 1.0) 401 402 403class SelectionShape(Shape): 404 name = _("Selection") 405 406 def __init__(self): 407 self.path = None 408 409 def process_selection(self, img): 410 if self.path is None: 411 self.path = SelectionToPath(img) 412 else: 413 self.path.regenerate_path_if_selection_changed() 414 415 def configure(self, img, pp, cp): 416 """ Set bounds of pattern """ 417 Shape.configure(self, img, pp, cp) 418 self.drawing_no = cp.current_drawing 419 self.path.set_current_stroke(self.drawing_no) 420 421 def get_num_drawings(self): 422 return self.path.get_num_strokes() 423 424 def can_equal_w_h(self): 425 return False 426 427 def get_center_of_moving_gear(self, oangle, dist=None): 428 """ 429 :param oangle: an angle in radians, between 0 and 2*pi 430 :return: x,y - position where the center of the moving gear should be, 431 after going over oangle/two_pi of a full cycle over the outer gear. 432 """ 433 cp = self.cp 434 if dist is None: 435 dist = cp.moving_gear_radius 436 x, y, slope, valid = self.path.point_at_angle(oangle) 437 slope_angle = atan(slope) 438 # We want to find an angle perpendicular to the slope, but in which direction? 439 # Lets try both sides and see which of them is inside the selection. 440 perpendicular_p, perpendicular_m = slope_angle + half_pi, slope_angle - half_pi 441 step_size = 2 # The distance we are going to go in the direction of each angle. 442 xp, yp = x + step_size * cos(perpendicular_p), y + step_size * sin(perpendicular_p) 443 value_plus = pdb.gimp_selection_value(self.image, xp, yp) 444 xp, yp = x + step_size * cos(perpendicular_m), y + step_size * sin(perpendicular_m) 445 value_minus = pdb.gimp_selection_value(self.image, xp, yp) 446 447 perpendicular = perpendicular_p if value_plus > value_minus else perpendicular_m 448 return x + dist * cos(perpendicular), y + dist * sin(perpendicular) 449 450 451shapes = [ 452 CircleShape(), RackShape(), FrameShape(), SelectionShape(), 453 PolygonShape(), SineShape(), BumpShape() 454] 455 456 457### Tools 458 459 460def get_gradient_samples(num_samples): 461 gradient_name = pdb.gimp_context_get_gradient() 462 reverse_mode = pdb.gimp_context_get_gradient_reverse() 463 repeat_mode = pdb.gimp_context_get_gradient_repeat_mode() 464 465 if repeat_mode == REPEAT_TRIANGULAR: 466 # Get two uniform samples, which are reversed from each other, and connect them. 467 468 samples = num_samples/2 + 1 469 num, color_samples = pdb.gimp_gradient_get_uniform_samples(gradient_name, 470 samples, reverse_mode) 471 472 color_samples = list(color_samples) 473 del color_samples[-4:] # Delete last color because it will appear in the next sample 474 475 # If num_samples is odd, lets get an extra sample this time. 476 if num_samples % 2 == 1: 477 samples += 1 478 479 num, color_samples2 = pdb.gimp_gradient_get_uniform_samples(gradient_name, 480 samples, 1 - reverse_mode) 481 482 color_samples2 = list(color_samples2) 483 del color_samples2[-4:] # Delete last color because it will appear in the very first sample 484 485 color_samples.extend(color_samples2) 486 color_samples = tuple(color_samples) 487 else: 488 num, color_samples = pdb.gimp_gradient_get_uniform_samples(gradient_name, num_samples, reverse_mode) 489 490 return color_samples 491 492 493class PencilTool(): 494 name = _("Pencil") 495 can_color = True 496 497 def draw(self, layer, strokes, color=None): 498 if color: 499 pdb.gimp_context_push() 500 pdb.gimp_context_set_dynamics('Dynamics Off') 501 pdb.gimp_context_set_foreground(color) 502 503 pdb.gimp_pencil(layer, len(strokes), strokes) 504 505 if color: 506 pdb.gimp_context_pop() 507 508 509class AirBrushTool(): 510 name = _("AirBrush") 511 can_color = True 512 513 def draw(self, layer, strokes, color=None): 514 if color: 515 pdb.gimp_context_push() 516 pdb.gimp_context_set_dynamics('Dynamics Off') 517 pdb.gimp_context_set_foreground(color) 518 519 pdb.gimp_airbrush_default(layer, len(strokes), strokes) 520 521 if color: 522 pdb.gimp_context_pop() 523 524 525class AbstractStrokeTool(): 526 527 def draw(self, layer, strokes, color=None): 528 # We need to multiply every point by 3, because we are creating a path, 529 # where each point has two additional control points. 530 control_points = [] 531 for i, k in zip(strokes[0::2], strokes[1::2]): 532 control_points += [i, k] * 3 533 534 # Create path 535 path = pdb.gimp_vectors_new(layer.image, 'temp_path') 536 pdb.gimp_image_add_vectors(layer.image, path, 0) 537 sid = pdb.gimp_vectors_stroke_new_from_points(path, 0, len(control_points), 538 control_points, False) 539 540 # Draw it. 541 542 pdb.gimp_context_push() 543 544 # Call template method to set the kind of stroke to draw. 545 self.prepare_stroke_context(color) 546 547 pdb.gimp_drawable_edit_stroke_item(layer, path) 548 pdb.gimp_context_pop() 549 550 # Get rid of the path. 551 pdb.gimp_image_remove_vectors(layer.image, path) 552 553 554# Drawing tool that should be quick, for purposes of previewing the pattern. 555class PreviewTool: 556 557 # Implementation using pencil. (A previous implementation using stroke was slower, and thus removed). 558 def draw(self, layer, strokes, color=None): 559 foreground = pdb.gimp_context_get_foreground() 560 pdb.gimp_context_push() 561 pdb.gimp_context_set_defaults() 562 pdb.gimp_context_set_foreground(foreground) 563 pdb.gimp_context_set_dynamics('Dynamics Off') 564 pdb.gimp_context_set_brush('1. Pixel') 565 pdb.gimp_context_set_brush_size(1.0) 566 pdb.gimp_context_set_brush_spacing(3.0) 567 pdb.gimp_pencil(layer, len(strokes), strokes) 568 pdb.gimp_context_pop() 569 570 name = _("Preview") 571 can_color = False 572 573 574class StrokeTool(AbstractStrokeTool): 575 name = _("Stroke") 576 can_color = True 577 578 def prepare_stroke_context(self, color): 579 if color: 580 pdb.gimp_context_set_dynamics('Dynamics Off') 581 pdb.gimp_context_set_foreground(color) 582 583 pdb.gimp_context_set_stroke_method(STROKE_LINE) 584 585 586class StrokePaintTool(AbstractStrokeTool): 587 def __init__(self, name, paint_method, can_color=True): 588 self.name = name 589 self.paint_method = paint_method 590 self.can_color = can_color 591 592 def prepare_stroke_context(self, color): 593 if self.can_color and color is not None: 594 pdb.gimp_context_set_dynamics('Dynamics Off') 595 pdb.gimp_context_set_foreground(color) 596 597 pdb.gimp_context_set_stroke_method(STROKE_PAINT_METHOD) 598 pdb.gimp_context_set_paint_method(self.paint_method) 599 600 601class SaveToPathTool(): 602 """ This tool cannot be chosen by the user from the tools menu. 603 We dont add this to the list of tools. """ 604 605 def __init__(self, img): 606 self.path = pdb.gimp_vectors_new(img, path_name) 607 pdb.gimp_image_add_vectors(img, self.path, 0) 608 609 def draw(self, layer, strokes, color=None): 610 # We need to multiply every point by 3, because we are creating a path, 611 # where each point has two additional control points. 612 control_points = [] 613 for i, k in zip(strokes[0::2], strokes[1::2]): 614 control_points += [i, k] * 3 615 616 sid = pdb.gimp_vectors_stroke_new_from_points(self.path, 0, len(control_points), 617 control_points, False) 618 619 620tools = [ 621 PreviewTool(), 622 StrokePaintTool(_("PaintBrush"), "gimp-paintbrush"), 623 PencilTool(), AirBrushTool(), StrokeTool(), 624 StrokePaintTool(_("Ink"), 'gimp-ink'), 625 StrokePaintTool(_("MyPaintBrush"), 'gimp-mybrush') 626 # Clone does not work properly when an image is not set. When that happens, drawing fails, and 627 # I am unable to catch the error. This causes the plugin to crash, and subsequent problems with undo. 628 # StrokePaintTool("Clone", 'gimp-clone', False) 629] 630 631 632class PatternParameters: 633 """ 634 All the parameters that define a pattern live in objects of this class. 635 If you serialize and saved this class, you should reproduce 636 the pattern that the plugin would draw. 637 """ 638 def __init__(self): 639 if not hasattr(self, 'curve_type'): 640 self.curve_type = 0 641 642 # Pattern 643 if not hasattr(self, 'pattern_notation'): 644 self.pattern_notation = 0 645 if not hasattr(self, 'outer_teeth'): 646 self.outer_teeth = 96 647 if not hasattr(self, 'inner_teeth'): 648 self.inner_teeth = 36 649 if not hasattr(self, 'pattern_rotation'): 650 self.pattern_rotation = 0 651 # Location of hole as a percent of the radius of the inner gear - runs between 0 and 100. 652 # A value of 0 means, the hole is at the center of the wheel, which would produce a boring circle. 653 # A value of 100 means the edge of the wheel. 654 if not hasattr(self, 'hole_percent'): 655 self.hole_percent = 100.0 656 657 # Toy Kit parameters 658 # Hole number in Toy Kit notation. Hole #1 is at the edge of the wheel, and the last hole is 659 # near the center of the wheel, but not exactly at the center. 660 if not hasattr(self, 'hole_number'): 661 self.hole_number = 1 662 if not hasattr(self, 'kit_fixed_gear_index'): 663 self.kit_fixed_gear_index = 1 664 if not hasattr(self, 'kit_moving_gear_index'): 665 self.kit_moving_gear_index = 1 666 667 # Visual notation parameters 668 if not hasattr(self, 'petals'): 669 self.petals = 5 670 if not hasattr(self, 'petal_skip'): 671 self.petal_skip = 2 672 if not hasattr(self, 'doughnut_hole'): 673 self.doughnut_hole = 50.0 674 if not hasattr(self, 'doughnut_width'): 675 self.doughnut_width = 50.0 676 677 # Shape 678 if not hasattr(self, 'shape_index'): 679 self.shape_index = 0 # Index in the shapes array 680 if not hasattr(self, 'sides'): 681 self.sides = 5 682 if not hasattr(self, 'morph'): 683 self.morph = 0.5 684 if not hasattr(self, 'shape_rotation'): 685 self.shape_rotation = 0 686 687 if not hasattr(self, 'equal_w_h'): 688 self.equal_w_h = False 689 if not hasattr(self, 'margin_pixels'): 690 self.margin_pixels = 0 # Distance between the drawn shape, and the selection borders. 691 692 # Drawing style 693 if not hasattr(self, 'tool_index'): 694 self.tool_index = 0 # Index in the tools array. 695 if not hasattr(self, 'long_gradient'): 696 self.long_gradient = False 697 698 if not hasattr(self, 'save_option'): 699 self.save_option = SAVE_AS_NEW_LAYER 700 701 def kit_max_hole_number(self): 702 return wheel[self.kit_moving_gear_index][1] 703 704 705# Handle shelving of plugin parameters 706 707def unshelf_parameters(): 708 if shelf.has_key("p"): 709 parameters = shelf["p"] 710 parameters.__init__() # Fill in missing values with defaults. 711 return parameters 712 713 return PatternParameters() 714 715 716def shelf_parameters(pp): 717 shelf["p"] = pp 718 719 720class ComputedParameters: 721 """ 722 Stores computations performed on a PatternParameters object. 723 The results of these computations are used to perform the drawing. 724 Having all these computations in one place makes it convenient to pass 725 around as a parameter. 726 727 If the pattern parameters should result in multiple pattern to be drawn, the 728 compute parameters also stores which one is currently being drawn. 729 """ 730 731 def __init__(self, pp, img): 732 733 def compute_gradients(): 734 self.use_gradient = self.pp.long_gradient and tools[self.pp.tool_index].can_color 735 736 # If gradient is used, determine how the lines are two be split to different colors. 737 if self.use_gradient: 738 # We want to use enough samples to be beautiful, but not too many, that would 739 # force us to make many separate calls for drawing the pattern. 740 if self.rotations > 30: 741 self.chunk_num = self.rotations 742 self.chunk_size_lines = self.fixed_gear_teeth 743 else: 744 # Lets try to find a chunk size, such that it divides num_lines, and we get at least 30 chunks. 745 # In the worse case, we will just use "1" 746 for chunk_size in range(self.fixed_gear_teeth - 1, 0, -1): 747 if self.num_lines % chunk_size == 0: 748 if self.num_lines / chunk_size > 30: 749 break 750 751 self.chunk_num = self.num_lines / chunk_size 752 self.chunk_size_lines = chunk_size 753 754 self.gradients = get_gradient_samples(self.chunk_num) 755 else: 756 self.chunk_num, self.chunk_size_lines = None, None 757 758 def compute_sizes(): 759 # Get rid of the margins. 760 self.x1 = x1 + pp.margin_pixels 761 self.y1 = y1 + pp.margin_pixels 762 self.x2 = x2 - pp.margin_pixels 763 self.y2 = y2 - pp.margin_pixels 764 765 # Compute size and position of the pattern 766 self.x_half_size, self.y_half_size = (self.x2 - self.x1) / 2, (self.y2 - self.y1) / 2 767 self.x_center, self.y_center = (self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0 768 769 if pp.equal_w_h: 770 if self.x_half_size < self.y_half_size: 771 self.y_half_size = self.x_half_size 772 self.y1, self.y2 = self.y_center - self.y_half_size, self.y_center + self.y_half_size 773 elif self.x_half_size > self.y_half_size: 774 self.x_half_size = self.y_half_size 775 self.x1, self.x2 = self.x_center - self.x_half_size, self.x_center + self.x_half_size 776 777 # Find the distance between the hole and the center of the inner circle. 778 # To do this, we compute the size of the gears, by the number of teeth. 779 # The circumference of the outer ring is 2 * pi * outer_R = #fixed_gear_teeth * tooth size. 780 outer_R = min(self.x_half_size, self.y_half_size) 781 if self.pp.pattern_notation == VISUAL_NOTATION: 782 doughnut_width = self.pp.doughnut_width 783 if doughnut_width + self.pp.doughnut_hole > 100: 784 doughnut_width = 100.0 - self.pp.doughnut_hole 785 786 # Let R, r be the radius of fixed and moving gear, and let hp be the hole percent. 787 # Let dwp, dhp be the doughnut width and hole in percents of R. 788 # The two sides of the following equation calculate how to reach the center of the moving 789 # gear from the center of the fixed gear: 790 # I) R * (dhp/100 + dwp/100/2) = R - r 791 # The following equation expresses which r and hp would generate a doughnut of width dw. 792 # II) R * dw/100 = 2 * r * hp/100 793 # We solve the two above equations to calculate hp and r: 794 self.hole_percent = doughnut_width / (2.0 * (1 - (self.pp.doughnut_hole + doughnut_width/2.0)/100.0)) 795 self.moving_gear_radius = outer_R * doughnut_width / (2 * self.hole_percent) 796 else: 797 size_of_tooth_in_pixels = two_pi * outer_R / self.fixed_gear_teeth 798 self.moving_gear_radius = size_of_tooth_in_pixels * self.moving_gear_teeth / two_pi 799 800 self.hole_dist_from_center = self.hole_percent / 100.0 * self.moving_gear_radius 801 802 self.pp = pp 803 804 # Check if the shape is made of multiple shapes, as in using Selection as fixed gear. 805 if (isinstance(shapes[self.pp.shape_index], SelectionShape) and 806 curve_types[self.pp.curve_type].supports_shapes()): 807 shapes[self.pp.shape_index].process_selection(img) 808 pdb.gimp_displays_flush() 809 self.num_drawings = shapes[self.pp.shape_index].get_num_drawings() 810 else: 811 self.num_drawings = 1 812 self.current_drawing = 0 813 814 # Get bounds. We don't care weather a selection exists or not. 815 exists, x1, y1, x2, y2 = pdb.gimp_selection_bounds(img) 816 817 # Combine different ways to specify patterns, into a unified set of computed parameters. 818 self.num_notation_drawings = 1 819 self.current_notation_drawing = 0 820 if self.pp.pattern_notation == GEAR_NOTATION: 821 self.fixed_gear_teeth = int(round(pp.outer_teeth)) 822 self.moving_gear_teeth = int(round(pp.inner_teeth)) 823 self.petals = self.num_petals() 824 self.hole_percent = pp.hole_percent 825 elif self.pp.pattern_notation == TOY_KIT_NOTATION: 826 self.fixed_gear_teeth = ring_teeth[pp.kit_fixed_gear_index] 827 self.moving_gear_teeth = wheel[pp.kit_moving_gear_index][0] 828 self.petals = self.num_petals() 829 # We want to map hole #1 to 100% and hole of max_hole_number to 2.5% 830 # We don't want 0% because that would be the exact center of the moving gear, 831 # and that would create a boring pattern. 832 max_hole_number = wheel[pp.kit_moving_gear_index][1] 833 self.hole_percent = (max_hole_number - pp.hole_number) / float(max_hole_number - 1) * 97.5 + 2.5 834 elif self.pp.pattern_notation == VISUAL_NOTATION: 835 self.petals = pp.petals 836 self.fixed_gear_teeth = pp.petals 837 self.moving_gear_teeth = pp.petals - pp.petal_skip 838 if self.moving_gear_teeth < 20: 839 self.fixed_gear_teeth *= 10 840 self.moving_gear_teeth *= 10 841 self.hole_percent = 100.0 842 self.num_notation_drawings = fractions.gcd(pp.petals, pp.petal_skip) 843 self.notation_drawings_rotation = two_pi/pp.petals 844 845 # Rotations 846 self.shape_rotation_radians = self.radians_from_degrees(pp.shape_rotation) 847 self.pattern_rotation_start_radians = self.radians_from_degrees(pp.pattern_rotation) 848 self.pattern_rotation_radians = self.pattern_rotation_start_radians 849 # Additional fixed pattern rotation for lissajous. 850 self.lissajous_rotation = two_pi/self.petals/4.0 851 852 # Compute the total number of teeth we have to go over. 853 # Another way to view it is the total of lines we are going to draw. 854 # To find this we compute the Least Common Multiplier. 855 self.num_lines = lcm(self.fixed_gear_teeth, self.moving_gear_teeth) 856 # The number of points we are going to compute. This is the number of lines, plus 1, because to draw 857 # a line we need two points. 858 self.num_points = self.num_lines + 1 859 860 # Compute gradients. 861 862 # The number or rotations needed in order to complete the pattern. 863 # Each rotation has cp.fixed_gear_teeth points + 1 points. 864 self.rotations = self.num_lines / self.fixed_gear_teeth 865 866 compute_gradients() 867 868 # Computations needed for the actual drawing of the patterns - how much should we advance each angle 869 # in each step of the computation. 870 871 # How many radians is each tooth of outer gear. This is also the amount that we 872 # will step in the iterations that generate the points of the pattern. 873 self.oangle_factor = two_pi / self.fixed_gear_teeth 874 # How many radians should the moving gear be moved, for each tooth of the fixed gear 875 angle_factor = curve_types[pp.curve_type].get_angle_factor(self) 876 self.iangle_factor = self.oangle_factor * angle_factor 877 878 compute_sizes() 879 880 def num_petals(self): 881 """ The number of 'petals' (or points) that will be produced by a spirograph drawing. """ 882 return lcm(self.fixed_gear_teeth, self.moving_gear_teeth) / self.moving_gear_teeth 883 884 def radians_from_degrees(self, degrees): 885 positive_degrees = degrees if degrees >= 0 else degrees + 360 886 return radians(positive_degrees) 887 888 def get_color(self, n): 889 return self.gradients[4*n:4*(n+1)] 890 891 def next_drawing(self): 892 """ Multiple drawings can be drawn either when the selection is used as a fixed 893 gear, and/or the visual tab is used, which causes multiple drawings 894 to be drawn at different rotations. """ 895 if self.current_notation_drawing < self.num_notation_drawings - 1: 896 self.current_notation_drawing += 1 897 self.pattern_rotation_radians = self.pattern_rotation_start_radians + ( 898 self.current_notation_drawing * self.notation_drawings_rotation) 899 else: 900 self.current_drawing += 1 901 self.current_notation_drawing = 0 902 self.pattern_rotation_radians = self.pattern_rotation_start_radians 903 904 def has_more_drawings(self): 905 return (self.current_notation_drawing < self.num_notation_drawings - 1 or 906 self.current_drawing < self.num_drawings - 1) 907 908 909### Curve types 910 911 912class CurveType: 913 914 def supports_shapes(self): 915 return True 916 917class RouletteCurveType(CurveType): 918 919 def get_strokes(self, p, cp): 920 strokes = [] 921 for curr_tooth in range(cp.num_points): 922 iangle = fmod(curr_tooth * cp.iangle_factor + cp.pattern_rotation_radians, two_pi) 923 oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, two_pi) 924 925 x, y = shapes[p.shape_index].get_center_of_moving_gear(oangle) 926 strokes.append(x + cp.hole_dist_from_center * cos(iangle)) 927 strokes.append(y + cp.hole_dist_from_center * sin(iangle)) 928 929 return strokes 930 931 932class SpyroCurveType(RouletteCurveType): 933 name = _("Spyrograph") 934 935 def get_angle_factor(self, cp): 936 return - (cp.fixed_gear_teeth - cp.moving_gear_teeth) / float(cp.moving_gear_teeth) 937 938 939class EpitrochoidCurvetype(RouletteCurveType): 940 name = _("Epitrochoid") 941 942 def get_angle_factor(self, cp): 943 return (cp.fixed_gear_teeth + cp.moving_gear_teeth) / float(cp.moving_gear_teeth) 944 945 946class SineCurveType(CurveType): 947 name = _("Sine") 948 949 def get_angle_factor(self, cp): 950 return cp.fixed_gear_teeth / float(cp.moving_gear_teeth) 951 952 def get_strokes(self, p, cp): 953 strokes = [] 954 for curr_tooth in range(cp.num_points): 955 iangle = curr_tooth * cp.iangle_factor 956 oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, two_pi) 957 958 dist = cp.moving_gear_radius + sin(iangle) * cp.hole_dist_from_center 959 x, y = shapes[p.shape_index].get_center_of_moving_gear(oangle, dist) 960 strokes.append(x) 961 strokes.append(y) 962 963 return strokes 964 965 966class LissaCurveType: 967 name = _("Lissajous") 968 969 def get_angle_factor(self, cp): 970 return cp.fixed_gear_teeth / float(cp.moving_gear_teeth) 971 972 def get_strokes(self, p, cp): 973 strokes = [] 974 for curr_tooth in range(cp.num_points): 975 iangle = curr_tooth * cp.iangle_factor 976 # Adding the cp.lissajous_rotation rotation makes the pattern have the same number of curves 977 # as the other curve types. Without it, many lissajous patterns would redraw the same lines twice, 978 # and thus look less dense than the other curves. 979 oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians + cp.lissajous_rotation, two_pi) 980 981 strokes.append(cp.x_center + cp.x_half_size * cos(oangle)) 982 strokes.append(cp.y_center + cp.y_half_size * cos(iangle)) 983 984 return strokes 985 986 def supports_shapes(self): 987 return False 988 989 990curve_types = [SpyroCurveType(), EpitrochoidCurvetype(), SineCurveType(), LissaCurveType()] 991 992# Drawing engine. Also implements drawing incrementally. 993# We don't draw the entire stroke, because it could take several seconds, 994# Instead, we break it into chunks. Incremental drawing is also used for drawing gradients. 995class DrawingEngine: 996 997 def __init__(self, img, p): 998 self.img, self.p = img, p 999 self.cp = None 1000 1001 # For incremental drawing 1002 self.strokes = [] 1003 self.start = 0 1004 self.chunk_size_lines = 600 1005 self.chunk_no = 0 1006 # We are aiming for the drawing time of a chunk to be no longer than max_time. 1007 self.max_time_sec = 0.1 1008 1009 self.dynamic_chunk_size = True 1010 1011 def pre_draw(self): 1012 """ Needs to be called before starting to draw a pattern. """ 1013 1014 self.cp = ComputedParameters(self.p, self.img) 1015 1016 def draw_full(self, layer): 1017 """ Non incremental drawing. """ 1018 1019 self.pre_draw() 1020 self.img.undo_group_start() 1021 1022 while true: 1023 self.set_strokes() 1024 1025 if self.cp.use_gradient: 1026 while self.has_more_strokes(): 1027 self.draw_next_chunk(layer, fetch_next_drawing=False) 1028 else: 1029 tools[self.p.tool_index].draw(layer, self.strokes) 1030 1031 if self.cp.has_more_drawings(): 1032 self.cp.next_drawing() 1033 else: 1034 break 1035 1036 self.img.undo_group_end() 1037 1038 pdb.gimp_displays_flush() 1039 1040 # Methods for incremental drawing. 1041 1042 def draw_next_chunk(self, layer, fetch_next_drawing=True, tool=None): 1043 stroke_chunk, color = self.next_chunk(fetch_next_drawing) 1044 if not tool: 1045 tool = tools[self.p.tool_index] 1046 tool.draw(layer, stroke_chunk, color) 1047 return len(stroke_chunk) 1048 1049 def set_strokes(self): 1050 """ Compute the strokes of the current pattern. The heart of the plugin. """ 1051 1052 shapes[self.p.shape_index].configure(self.img, self.p, self.cp) 1053 1054 self.strokes = curve_types[self.p.curve_type].get_strokes(self.p, self.cp) 1055 1056 self.start = 0 1057 self.chunk_no = 0 1058 1059 if self.cp.use_gradient: 1060 self.chunk_size_lines = self.cp.chunk_size_lines 1061 self.dynamic_chunk_size = False 1062 else: 1063 self.dynamic_chunk_size = True 1064 1065 def reset_incremental(self): 1066 """ Setup incremental drawing to start drawing from scratch. """ 1067 self.pre_draw() 1068 self.set_strokes() 1069 1070 def next_chunk(self, fetch_next_drawing): 1071 1072 # chunk_size_lines, is the number of lines we want to draw. We need 1 extra point to draw that. 1073 end = self.start + (self.chunk_size_lines + 1) * 2 1074 if end > len(self.strokes): 1075 end = len(self.strokes) 1076 result = self.strokes[self.start:end] 1077 # Promote the start to the last point. This is the start of the first line to draw next time. 1078 self.start = end - 2 1079 color = self.cp.get_color(self.chunk_no) if self.cp.use_gradient else None 1080 1081 self.chunk_no += 1 1082 1083 # If self.strokes has ended, lets fetch strokes for the next drawing. 1084 if fetch_next_drawing and not self.has_more_strokes(): 1085 if self.cp.has_more_drawings(): 1086 self.cp.next_drawing() 1087 self.set_strokes() 1088 1089 return result, color 1090 1091 def has_more_strokes(self): 1092 return self.start + 2 < len(self.strokes) 1093 1094 # Used for displaying progress. 1095 def fraction_done(self): 1096 return (self.start + 2.0) / len(self.strokes) 1097 1098 def report_time(self, time_sec): 1099 """ 1100 Report the time it took, in seconds, to draw the last stroke chunk. 1101 This helps to determine the size of chunks to return in future calls of 'next_chunk', 1102 since we want the calls to be short, to not make the user interface feel stuck. 1103 """ 1104 if time_sec != 0 and self.dynamic_chunk_size: 1105 self.chunk_size_lines = int(self.chunk_size_lines * self.max_time_sec / time_sec) 1106 # Don't let chunk size be too large or small. 1107 self.chunk_size_lines = max(10, self.chunk_size_lines) 1108 self.chunk_size_lines = min(1000, self.chunk_size_lines) 1109 1110 1111# Constants for DoughnutWidget 1112 1113# Enum - When the mouse is pressed, which target value is being changed. 1114TARGET_NONE, TARGET_HOLE, TARGET_WIDTH = range(3) 1115 1116CIRCLE_CENTER_X = 4 1117RIGHT_MARGIN = 2 1118TOTAL_MARGIN = CIRCLE_CENTER_X + RIGHT_MARGIN 1119 1120# A widget for displaying and setting the pattern of a spirograph, using a "doughnut" as 1121# a visual metaphore. This widget replaces two scale widgets. 1122class DoughnutWidget(gtk.DrawingArea): 1123 __gtype_name__ = 'DoughnutWidget' 1124 1125 def __init__(self, *args, **kwds): 1126 super(DoughnutWidget, self).__init__(*args, **kwds) 1127 self.set_size_request(80, 40) 1128 1129 self.add_events( 1130 gdk.BUTTON1_MOTION_MASK | 1131 gdk.BUTTON_PRESS_MASK | 1132 gdk.BUTTON_RELEASE_MASK | 1133 gdk.POINTER_MOTION_MASK 1134 ) 1135 1136 self.default_cursor = self.get_screen().get_root_window().get_cursor() 1137 self.resize_cursor = gdk.Cursor(gdk.SB_H_DOUBLE_ARROW) 1138 1139 self.button_pressed = False 1140 self.target = TARGET_NONE 1141 1142 self.hole_radius = 30 1143 self.doughnut_width = 30 1144 self.connect("expose-event", self.expose) 1145 1146 def set_hole_radius(self, hole_radius): 1147 self.queue_draw() 1148 self.hole_radius = hole_radius 1149 1150 def get_hole_radius(self): 1151 return self.hole_radius 1152 1153 def set_width(self, width): 1154 self.queue_draw() 1155 self.doughnut_width = width 1156 1157 def get_width(self): 1158 return self.doughnut_width 1159 1160 def compute_doughnut(self): 1161 """ Compute the location of the doughnut circles. 1162 Returns (circle center x, circle center y, radius of inner circle, radius of outer circle) """ 1163 allocation = self.get_allocation() 1164 alloc_width = allocation.width - TOTAL_MARGIN 1165 return ( 1166 CIRCLE_CENTER_X, allocation.height / 2, 1167 alloc_width * self.hole_radius / 100.0, 1168 alloc_width * min(self.hole_radius + self.doughnut_width, 100.0) / 100.0 1169 ) 1170 1171 def set_cursor_h_resize(self): 1172 """Set the mouse to be a double arrow.""" 1173 gdk_window = self.get_window() 1174 gdk_window.set_cursor(self.resize_cursor) 1175 1176 def set_default_cursor(self): 1177 gdk_window = self.get_window() 1178 gdk_window.set_cursor(self.default_cursor) 1179 1180 def get_target(self, x, y): 1181 # Find out if x, y is over one of the circle edges. 1182 1183 center_x, center_y, hole_radius, outer_radius = self.compute_doughnut() 1184 1185 # Compute distance from circle center to point 1186 dist = sqrt((center_x - x) ** 2 + (center_y - y) ** 2) 1187 1188 if abs(dist - hole_radius) <= 3: 1189 return TARGET_HOLE 1190 if abs(dist - outer_radius) <= 3: 1191 return TARGET_WIDTH 1192 1193 return TARGET_NONE 1194 1195 def expose(self, widget, event): 1196 1197 cr = widget.window.cairo_create() 1198 center_x, center_y, hole_radius, outer_radius = self.compute_doughnut() 1199 fg_color = gtk.widget_get_default_style().fg[gtk.STATE_NORMAL] 1200 1201 # Draw doughnut interior 1202 arc = pi * 3 / 2.0 1203 cr.set_source_rgba(fg_color.red, fg_color.green, fg_color.blue, 0.5) 1204 cr.arc(center_x, center_y, hole_radius, -arc, arc) 1205 cr.arc_negative(center_x, center_y, outer_radius, arc, -arc) 1206 cr.close_path() 1207 cr.fill() 1208 1209 # Draw doughnut border. 1210 cr.set_source_rgb(fg_color.red, fg_color.green, fg_color.blue) 1211 cr.set_line_width(3) 1212 cr.arc_negative(center_x, center_y, outer_radius, arc, -arc) 1213 cr.stroke() 1214 if hole_radius < 1.0: 1215 # If the radius is too small, nothing will be drawn, so draw a small cross marker instead. 1216 cr.set_line_width(2) 1217 cr.move_to(center_x - 4, center_y) 1218 cr.line_to(center_x + 4, center_y) 1219 cr.move_to(center_x, center_y - 4) 1220 cr.line_to(center_x, center_y + 4) 1221 else: 1222 cr.arc(center_x, center_y, hole_radius, -arc, arc) 1223 cr.stroke() 1224 1225 def compute_new_radius(self, x): 1226 """ This method is called during mouse dragging of the widget. 1227 Compute the new radius based on the current x location of the mouse pointer. """ 1228 allocation = self.get_allocation() 1229 1230 # How much does a single pixel difference in x, change the radius? 1231 # Note that: allocation.width - TOTAL_MARGIN = 100 radius units, 1232 radius_per_pixel = 100.0 / (allocation.width - TOTAL_MARGIN) 1233 new_radius = self.start_radius + (x - self.start_x) * radius_per_pixel 1234 1235 if self.target == TARGET_HOLE: 1236 self.hole_radius = max(min(new_radius, 99.0), 0.0) 1237 else: 1238 self.doughnut_width = max(min(new_radius, 100.0), 1.0) 1239 1240 self.queue_draw() 1241 1242 def do_button_press_event(self, event): 1243 self.button_pressed = True 1244 1245 # If we clicked on one of the doughnut borders, remember which 1246 # border we clicked on, and setup variable to start dragging it. 1247 target = self.get_target(event.x, event.y) 1248 if target == TARGET_HOLE or target == TARGET_WIDTH: 1249 self.target = target 1250 self.start_x = event.x 1251 self.start_radius = ( 1252 self.hole_radius if target == TARGET_HOLE else 1253 self.doughnut_width 1254 ) 1255 1256 def do_button_release_event(self, event): 1257 # If one the doughnut borders was being dragged, recompute the doughnut size. 1258 if self.target != TARGET_NONE: 1259 self.compute_new_radius(event.x) 1260 # Clip the width, if it is too large to fit. 1261 if self.hole_radius + self.doughnut_width > 100: 1262 self.doughnut_width = 100 - self.hole_radius 1263 self.emit("values_changed", self.hole_radius, self.doughnut_width) 1264 1265 self.button_pressed = False 1266 self.target = TARGET_NONE 1267 1268 def do_motion_notify_event(self, event): 1269 if self.button_pressed: 1270 # We are dragging one of the doughnut borders; recompute its size. 1271 if self.target != TARGET_NONE: 1272 self.compute_new_radius(event.x) 1273 else: 1274 # Set cursor according to whether we are over one of the 1275 # doughnut borders. 1276 target = self.get_target(event.x, event.y) 1277 if target == TARGET_NONE: 1278 self.set_default_cursor() 1279 else: 1280 self.set_cursor_h_resize() 1281 1282 1283# Create signal that returns change parameters. 1284gobject.type_register(DoughnutWidget) 1285gobject.signal_new("values_changed", DoughnutWidget, gobject.SIGNAL_RUN_LAST, 1286 gobject.TYPE_NONE, (gobject.TYPE_INT, gobject.TYPE_INT)) 1287 1288 1289class SpyroWindow(gtk.Window): 1290 1291 # Define signal to catch escape key. 1292 __gsignals__ = dict( 1293 myescape=(gobject.SIGNAL_RUN_LAST | gobject.SIGNAL_ACTION, 1294 None, # return type 1295 (str,)) # arguments 1296 ) 1297 1298 class MyScale(): 1299 """ Combintation of scale and spin that control the same adjuster. """ 1300 def __init__(self, scale, spin): 1301 self.scale, self.spin = scale, spin 1302 1303 def set_sensitive(self, val): 1304 self.scale.set_sensitive(val) 1305 self.spin.set_sensitive(val) 1306 1307 def __init__(self, img, layer): 1308 1309 def add_horizontal_separator(vbox): 1310 hsep = gtk.HSeparator() 1311 vbox.add(hsep) 1312 hsep.show() 1313 1314 def add_vertical_space(vbox, height): 1315 hbox = gtk.HBox() 1316 hbox.set_border_width(height/2) 1317 vbox.add(hbox) 1318 hbox.show() 1319 1320 def add_to_box(box, w): 1321 box.add(w) 1322 w.show() 1323 1324 def create_table(rows, columns, border_width): 1325 table = gtk.Table(rows=rows, columns=columns, homogeneous=False) 1326 table.set_border_width(border_width) 1327 table.set_col_spacings(10) 1328 table.set_row_spacings(10) 1329 return table 1330 1331 def label_in_table(label_text, table, row, tooltip_text=None, col=0, col_add=1): 1332 """ Create a label and set it in first col of table. """ 1333 label = gtk.Label(label_text) 1334 label.set_alignment(xalign=0.0, yalign=1.0) 1335 if tooltip_text: 1336 label.set_tooltip_text(tooltip_text) 1337 table.attach(label, col, col + col_add, row, row + 1, xoptions=gtk.FILL, yoptions=0) 1338 label.show() 1339 1340 def spin_in_table(adj, table, row, callback, digits=0, col=0): 1341 spin = gtk.SpinButton(adj, climb_rate=0.5, digits=digits) 1342 spin.set_numeric(True) 1343 spin.set_snap_to_ticks(True) 1344 spin.set_max_length(5) 1345 spin.set_width_chars(5) 1346 table.attach(spin, col, col + 1, row, row + 1, xoptions=0, yoptions=0) 1347 spin.show() 1348 adj.connect("value_changed", callback) 1349 return spin 1350 1351 def hscale_in_table(adj, table, row, callback, digits=0, col=1, cols=1): 1352 """ Create an hscale and a spinner using the same Adjustment, and set it in table. """ 1353 scale = gtk.HScale(adj) 1354 scale.set_size_request(150, -1) 1355 scale.set_digits(digits) 1356 scale.set_update_policy(gtk.UPDATE_DISCONTINUOUS) 1357 table.attach(scale, col, col + cols, row, row + 1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0) 1358 scale.show() 1359 1360 spin = gtk.SpinButton(adj, climb_rate=0.5, digits=digits) 1361 spin.set_numeric(True) 1362 spin.set_snap_to_ticks(True) 1363 spin.set_max_length(5) 1364 spin.set_width_chars(5) 1365 table.attach(spin, col + cols , col + cols + 1, row, row + 1, xoptions=0, yoptions=0) 1366 spin.show() 1367 1368 adj.connect("value_changed", callback) 1369 1370 return self.MyScale(scale, spin) 1371 1372 def rotation_in_table(val, table, row, callback): 1373 adj = gtk.Adjustment(val, -180.0, 180.0, 1.0) 1374 myscale = hscale_in_table(adj, table, row, callback, digits=1) 1375 myscale.scale.add_mark(0.0, gtk.POS_BOTTOM, None) 1376 myscale.spin.set_max_length(6) 1377 myscale.spin.set_width_chars(6) 1378 return adj, myscale 1379 1380 def set_combo_in_table(txt_list, table, row, callback): 1381 combo = gtk.combo_box_new_text() 1382 for txt in txt_list: 1383 combo.append_text(txt) 1384 table.attach(combo, 1, 2, row, row + 1, xoptions=gtk.FILL, yoptions=0) 1385 combo.show() 1386 combo.connect("changed", callback) 1387 return combo 1388 1389 # Return table which is at the top of the dialog, and has several major input widgets. 1390 def top_table(): 1391 1392 # Add table for displaying attributes, each having a label and an input widget. 1393 table = create_table(2, 3, 10) 1394 1395 # Curve type 1396 row = 0 1397 label_in_table(_("Curve Type"), table, row, 1398 _("An Epitrochoid pattern is when the moving gear is on the outside of the fixed gear.")) 1399 self.curve_type_combo = set_combo_in_table([ct.name for ct in curve_types], table, row, 1400 self.curve_type_changed) 1401 1402 row += 1 1403 label_in_table(_("Tool"), table, row, 1404 _("The tool with which to draw the pattern. " 1405 "The Preview tool just draws quickly.")) 1406 self.tool_combo = set_combo_in_table([tool.name for tool in tools], table, row, 1407 self.tool_combo_changed) 1408 1409 self.long_gradient_checkbox = gtk.CheckButton(_("Long Gradient")) 1410 self.long_gradient_checkbox.set_tooltip_text( 1411 _("When unchecked, the current tool settings will be used. " 1412 "When checked, will use a long gradient to match the length of the pattern, " 1413 "based on current gradient and repeat mode from the gradient tool settings.") 1414 ) 1415 self.long_gradient_checkbox.set_border_width(0) 1416 table.attach(self.long_gradient_checkbox, 2, 3, row, row + 1, xoptions=0, yoptions=0) 1417 self.long_gradient_checkbox.show() 1418 self.long_gradient_checkbox.connect("toggled", self.long_gradient_changed) 1419 1420 return table 1421 1422 def pattern_notation_frame(): 1423 1424 vbox = gtk.VBox(spacing=0, homogeneous=False) 1425 1426 add_vertical_space(vbox, 14) 1427 1428 hbox = gtk.HBox(spacing=5) 1429 hbox.set_border_width(5) 1430 1431 label = gtk.Label(_("Specify pattern using one of the following tabs:")) 1432 label.set_tooltip_text(_( 1433 "The pattern is specified only by the active tab. Toy Kit is similar to Gears, " 1434 "but it uses gears and hole numbers which are found in toy kits. " 1435 "If you follow the instructions from the toy kit manuals, results should be similar.")) 1436 hbox.pack_start(label) 1437 label.show() 1438 1439 alignment = gtk.Alignment(xalign=0.0, yalign=0.0, xscale=0.0, yscale=0.0) 1440 alignment.add(hbox) 1441 hbox.show() 1442 vbox.add(alignment) 1443 alignment.show() 1444 1445 self.pattern_notebook = gtk.Notebook() 1446 self.pattern_notebook.set_border_width(0) 1447 self.pattern_notebook.connect('switch-page', self.pattern_notation_tab_changed) 1448 1449 # "Gear" pattern notation. 1450 1451 # Add table for displaying attributes, each having a label and an input widget. 1452 gear_table = create_table(3, 3, 5) 1453 1454 # Teeth 1455 row = 0 1456 fixed_gear_tooltip = _( 1457 "Number of teeth of fixed gear. The size of the fixed gear is " 1458 "proportional to the number of teeth." 1459 ) 1460 label_in_table(_("Fixed Gear Teeth"), gear_table, row, fixed_gear_tooltip) 1461 self.outer_teeth_adj = gtk.Adjustment(self.p.outer_teeth, 10, 180, 1) 1462 hscale_in_table(self.outer_teeth_adj, gear_table, row, self.outer_teeth_changed) 1463 1464 row += 1 1465 moving_gear_tooltip = _( 1466 "Number of teeth of moving gear. The size of the moving gear is " 1467 "proportional to the number of teeth." 1468 ) 1469 label_in_table(_("Moving Gear Teeth"), gear_table, row, moving_gear_tooltip) 1470 self.inner_teeth_adj = gtk.Adjustment(self.p.inner_teeth, 2, 100, 1) 1471 hscale_in_table(self.inner_teeth_adj, gear_table, row, self.inner_teeth_changed) 1472 1473 row += 1 1474 label_in_table(_("Hole percent"), gear_table, row, 1475 _("How far is the hole from the center of the moving gear. " 1476 "100% means that the hole is at the gear's edge.")) 1477 self.hole_percent_adj = gtk.Adjustment(self.p.hole_percent, 2.5, 100.0, 0.5) 1478 self.hole_percent_myscale = hscale_in_table(self.hole_percent_adj, gear_table, 1479 row, self.hole_percent_changed, digits=1) 1480 1481 # "Kit" pattern notation. 1482 1483 kit_table = create_table(3, 3, 5) 1484 1485 row = 0 1486 label_in_table(_("Fixed Gear Teeth"), kit_table, row, fixed_gear_tooltip) 1487 self.kit_outer_teeth_combo = set_combo_in_table([str(t) for t in ring_teeth], kit_table, row, 1488 self.kit_outer_teeth_combo_changed) 1489 1490 row += 1 1491 label_in_table(_("Moving Gear Teeth"), kit_table, row, moving_gear_tooltip) 1492 self.kit_inner_teeth_combo = set_combo_in_table([str(t) for t in wheel_teeth], kit_table, row, 1493 self.kit_inner_teeth_combo_changed) 1494 1495 row += 1 1496 label_in_table(_("Hole Number"), kit_table, row, 1497 _("Hole #1 is at the edge of the gear. " 1498 "The maximum hole number is near the center. " 1499 "The maximum hole number is different for each gear.")) 1500 self.kit_hole_adj = gtk.Adjustment(self.p.hole_number, 1, self.p.kit_max_hole_number(), 1) 1501 self.kit_hole_myscale = hscale_in_table(self.kit_hole_adj, kit_table, row, self.kit_hole_changed) 1502 1503 # "Visual" pattern notation. 1504 1505 visual_table = create_table(3, 5, 5) 1506 1507 row = 0 1508 label_in_table(_("Flower Petals"), visual_table, row, _("The number of petals in the pattern.")) 1509 self.petals_adj = gtk.Adjustment(self.p.petals, 2, 100, 1) 1510 hscale_in_table(self.petals_adj, visual_table, row, self.petals_changed, cols=3) 1511 1512 row += 1 1513 label_in_table(_("Petal Skip"), visual_table, row, 1514 _("The number of petals to advance for drawing the next petal.")) 1515 self.petal_skip_adj = gtk.Adjustment(self.p.petal_skip, 1, 50, 1) 1516 hscale_in_table(self.petal_skip_adj, visual_table, row, self.petal_skip_changed, cols=3) 1517 1518 row += 1 1519 label_in_table(_("Hole Radius(%)"), visual_table, row, 1520 _("The radius of the hole in the center of the pattern " 1521 "where nothing will be drawn. Given as a percentage of the " 1522 "size of the pattern. A value of 0 will produce no hole. " 1523 "A Value of 99 will produce a thin line on the edge.")) 1524 self.doughnut_hole_adj = gtk.Adjustment(self.p.doughnut_hole, 0.0, 99.0, 0.1) 1525 self.doughnut_hole_myscale = spin_in_table(self.doughnut_hole_adj, 1526 visual_table, row, self.doughnut_hole_changed, 1, 1) 1527 1528 self.doughnut = DoughnutWidget() 1529 visual_table.attach(self.doughnut, 2, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0) 1530 self.doughnut.connect('values_changed', self.doughnut_changed) 1531 self.doughnut.show() 1532 1533 label_in_table(_("Width(%)"), visual_table, row, 1534 _("The width of the pattern as a percentage of the " 1535 "size of the pattern. A Value of 1 will just draw a thin pattern. " 1536 "A Value of 100 will fill the entire fixed gear."), 3) 1537 self.doughnut_width_adj = gtk.Adjustment(self.p.doughnut_width, 1.0, 100.0, 0.1) 1538 self.doughnut_width_myscale = spin_in_table(self.doughnut_width_adj, 1539 visual_table, row, self.doughnut_width_changed, 1, 4) 1540 1541 # Add tables as children of the pattern notebook 1542 1543 pattern_notation_page[VISUAL_NOTATION] = self.pattern_notebook.append_page(visual_table) 1544 self.pattern_notebook.set_tab_label_text(visual_table, _("Visual")) 1545 self.pattern_notebook.set_tab_label_packing(visual_table, 0, 0, gtk.PACK_END) 1546 visual_table.show() 1547 1548 pattern_notation_page[TOY_KIT_NOTATION] = self.pattern_notebook.append_page(kit_table) 1549 self.pattern_notebook.set_tab_label_text(kit_table, _("Toy Kit")) 1550 self.pattern_notebook.set_tab_label_packing(kit_table, 0, 0, gtk.PACK_END) 1551 kit_table.show() 1552 1553 pattern_notation_page[GEAR_NOTATION] = self.pattern_notebook.append_page(gear_table) 1554 self.pattern_notebook.set_tab_label_text(gear_table, _("Gears")) 1555 self.pattern_notebook.set_tab_label_packing(gear_table, 0, 0, gtk.PACK_END) 1556 gear_table.show() 1557 1558 add_to_box(vbox, self.pattern_notebook) 1559 1560 add_vertical_space(vbox, 14) 1561 1562 hbox = gtk.HBox(spacing=5) 1563 pattern_table = create_table(1, 3, 5) 1564 1565 row = 0 1566 label_in_table(_("Rotation"), pattern_table, row, 1567 _("Rotation of the pattern, in degrees. " 1568 "The starting position of the moving gear in the fixed gear.")) 1569 self.pattern_rotation_adj, myscale = rotation_in_table( 1570 self.p.pattern_rotation, pattern_table, row, self.pattern_rotation_changed 1571 ) 1572 1573 hbox.pack_end(pattern_table, expand=True, fill=True, padding=0) 1574 pattern_table.show() 1575 1576 vbox.add(hbox) 1577 hbox.show() 1578 1579 return vbox 1580 1581 def fixed_gear_page(): 1582 1583 vbox = gtk.VBox(spacing=0, homogeneous=False) 1584 1585 add_vertical_space(vbox, 14) 1586 1587 table = create_table(4, 2, 10) 1588 1589 row = 0 1590 label_in_table(_("Shape"), table, row, 1591 _("The shape of the fixed gear to be used inside current selection. " 1592 "Rack is a long round-edged shape provided in the toy kits. " 1593 "Frame hugs the boundaries of the rectangular selection, " 1594 "use hole=100 in Gear notation to touch boundary. " 1595 "Selection will hug boundaries of current selection - try something non-rectangular.")) 1596 self.shape_combo = set_combo_in_table([shape.name for shape in shapes], table, row, 1597 self.shape_combo_changed) 1598 1599 row += 1 1600 label_in_table(_("Sides"), table, row, _("Number of sides of the shape.")) 1601 self.sides_adj = gtk.Adjustment(self.p.sides, 3, 16, 1) 1602 self.sides_myscale = hscale_in_table(self.sides_adj, table, row, self.sides_changed) 1603 1604 row += 1 1605 label_in_table(_("Morph"), table, row, _("Morph fixed gear shape. Only affects some of the shapes.")) 1606 self.morph_adj = gtk.Adjustment(self.p.morph, 0.0, 1.0, 0.01) 1607 self.morph_myscale = hscale_in_table(self.morph_adj, table, row, self.morph_changed, digits=2) 1608 1609 row += 1 1610 label_in_table(_("Rotation"), table, row, _("Rotation of the fixed gear, in degrees")) 1611 self.shape_rotation_adj, self.shape_rotation_myscale = rotation_in_table( 1612 self.p.shape_rotation, table, row, self.shape_rotation_changed 1613 ) 1614 1615 add_to_box(vbox, table) 1616 return vbox 1617 1618 def size_page(): 1619 1620 vbox = gtk.VBox(spacing=0, homogeneous=False) 1621 add_vertical_space(vbox, 14) 1622 table = create_table(2, 2, 10) 1623 1624 row = 0 1625 label_in_table(_("Margin (px)"), table, row, _("Margin from edge of selection.")) 1626 self.margin_adj = gtk.Adjustment(self.p.margin_pixels, 0, max(img.height, img.width), 1) 1627 hscale_in_table(self.margin_adj, table, row, self.margin_changed) 1628 1629 row += 1 1630 self.equal_w_h_checkbox = gtk.CheckButton(_("Make width and height equal")) 1631 self.equal_w_h_checkbox.set_tooltip_text( 1632 _("When unchecked, the pattern will fill the current image or selection. " 1633 "When checked, the pattern will have same width and height, and will be centered.") 1634 ) 1635 self.equal_w_h_checkbox.set_border_width(15) 1636 table.attach(self.equal_w_h_checkbox, 0, 2, row, row + 1) 1637 self.equal_w_h_checkbox.show() 1638 self.equal_w_h_checkbox.connect("toggled", self.equal_w_h_checkbox_changed) 1639 1640 1641 add_to_box(vbox, table) 1642 return vbox 1643 1644 def add_button_to_box(box, text, callback, tooltip_text=None): 1645 btn = gtk.Button(text) 1646 if tooltip_text: 1647 btn.set_tooltip_text(tooltip_text) 1648 box.add(btn) 1649 btn.show() 1650 btn.connect("clicked", callback) 1651 return btn 1652 1653 def dialog_button_box(): 1654 hbox = gtk.HBox(homogeneous=True, spacing=20) 1655 1656 add_button_to_box(hbox, _("Re_draw"), self.redraw, 1657 _("If you change the settings of a tool, change color, or change the selection, " 1658 "press this to preview how the pattern looks.")) 1659 add_button_to_box(hbox, _("_Reset"), self.reset_params) 1660 add_button_to_box(hbox, _("_Cancel"), self.cancel_window) 1661 self.ok_btn = add_button_to_box(hbox, _("_OK"), self.ok_window) 1662 1663 self.save_option_combo = gtk.combo_box_new_text() 1664 for txt in save_options: 1665 self.save_option_combo.append_text(txt) 1666 self.save_option_combo.set_tooltip_text( 1667 _("Choose whether to save as new layer, redraw on last active layer, or save to path") 1668 ) 1669 hbox.add(self.save_option_combo) 1670 self.save_option_combo.show() 1671 self.save_option_combo.connect("changed", self.save_option_changed) 1672 1673 return hbox 1674 1675 def create_ui(): 1676 1677 # Create the dialog 1678 gtk.Window.__init__(self) 1679 self.set_title(_("Spyrogimp")) 1680 self.set_default_size(350, -1) 1681 self.set_border_width(10) 1682 # self.set_keep_above(True) # keep the window on top 1683 1684 # Vertical box in which we will add all the UI elements. 1685 vbox = gtk.VBox(spacing=10, homogeneous=False) 1686 self.add(vbox) 1687 1688 box = gimpui.HintBox(_("Draw spyrographs using current tool settings and selection.")) 1689 vbox.pack_start(box, expand=False) 1690 box.show() 1691 1692 add_horizontal_separator(vbox) 1693 1694 add_to_box(vbox, top_table()) 1695 1696 self.main_notebook = gtk.Notebook() 1697 self.main_notebook.set_show_tabs(True) 1698 self.main_notebook.set_border_width(5) 1699 1700 pattern_frame = pattern_notation_frame() 1701 self.main_notebook.append_page(pattern_frame, gtk.Label(_("Curve Pattern"))) 1702 pattern_frame.show() 1703 fixed_g_page = fixed_gear_page() 1704 self.main_notebook.append_page(fixed_g_page, gtk.Label(_("Fixed Gear"))) 1705 fixed_g_page.show() 1706 size_p = size_page() 1707 self.main_notebook.append_page(size_p, gtk.Label(_("Size"))) 1708 size_p.show() 1709 1710 vbox.add(self.main_notebook) 1711 self.main_notebook.show() 1712 1713 add_horizontal_separator(vbox) 1714 1715 self.progress_bar = gtk.ProgressBar() # gimpui.ProgressBar() - causes gimppdbprogress error message. 1716 self.progress_bar.set_size_request(-1, 30) 1717 vbox.add(self.progress_bar) 1718 self.progress_bar.show() 1719 1720 add_to_box(vbox, dialog_button_box()) 1721 1722 vbox.show() 1723 self.show() 1724 1725 self.enable_incremental_drawing = False 1726 1727 self.img = img 1728 # Remember active layer, so we can restore it when the plugin is done. 1729 self.active_layer = layer 1730 1731 self.p = unshelf_parameters() # Model 1732 1733 self.engine = DrawingEngine(img, self.p) 1734 1735 # Make a new GIMP layer to draw on 1736 self.spyro_layer = gimp.Layer(img, layer_name, img.width, img.height, 1737 layer.type_with_alpha, 100, NORMAL_MODE) 1738 img.add_layer(self.spyro_layer, 0) 1739 1740 self.drawing_layer = self.spyro_layer 1741 1742 gimpui.gimp_ui_init() 1743 create_ui() 1744 self.update_view() 1745 1746 # Obey the window manager quit signal 1747 self.connect("destroy", self.cancel_window) 1748 # Connect Escape key to quit the window as well. 1749 self.connect('myescape', self.cancel_window) 1750 1751 # Setup for Handling incremental/interactive drawing of pattern 1752 self.idle_task = None 1753 self.enable_incremental_drawing = True 1754 1755 # Draw pattern of the current settings. 1756 self.start_new_incremental_drawing() 1757 1758 # Callbacks for closing the plugin 1759 1760 def clear_idle_task(self): 1761 if self.idle_task: 1762 gobject.source_remove(self.idle_task) 1763 # Close the undo group in the likely case the idle task left it open. 1764 self.img.undo_group_end() 1765 self.idle_task = None 1766 1767 def ok_window(self, widget): 1768 """ Called when clicking on the 'close' button. """ 1769 1770 self.ok_btn.set_sensitive(False) 1771 1772 shelf_parameters(self.p) 1773 1774 if self.p.save_option == SAVE_AS_NEW_LAYER: 1775 if self.spyro_layer in self.img.layers: 1776 self.img.active_layer = self.spyro_layer 1777 1778 # If we are in the middle of incremental draw, we want to complete it, and only then to exit. 1779 # However, in order to complete it, we need to create another idle task. 1780 if self.idle_task: 1781 def quit_dialog_on_completion(): 1782 while self.idle_task: 1783 yield True 1784 1785 gtk.main_quit() # This will quit the dialog. 1786 yield False 1787 1788 task = quit_dialog_on_completion() 1789 gobject.idle_add(task.next) 1790 else: 1791 gtk.main_quit() 1792 else: 1793 # If there is an incremental drawing taking place, lets stop it. 1794 self.clear_idle_task() 1795 1796 if self.spyro_layer in self.img.layers: 1797 self.img.remove_layer(self.spyro_layer) 1798 self.img.active_layer = self.active_layer 1799 1800 self.drawing_layer = self.active_layer 1801 1802 def draw_full(tool): 1803 self.progress_start() 1804 yield True 1805 1806 self.engine.reset_incremental() 1807 1808 self.img.undo_group_start() 1809 1810 while self.engine.has_more_strokes(): 1811 yield True 1812 self.draw_next_chunk(tool=tool) 1813 1814 self.img.undo_group_end() 1815 1816 pdb.gimp_displays_flush() 1817 1818 gtk.main_quit() 1819 yield False 1820 1821 tool = SaveToPathTool(self.img) if self.p.save_option == SAVE_AS_PATH else None 1822 task = draw_full(tool) 1823 gobject.idle_add(task.next) 1824 1825 def cancel_window(self, widget, what=None): 1826 self.clear_idle_task() 1827 1828 # We want to delete the temporary layer, but as a precaution, lets ask first, 1829 # maybe it was already deleted by the user. 1830 if self.spyro_layer in self.img.layers: 1831 self.img.remove_layer(self.spyro_layer) 1832 pdb.gimp_displays_flush() 1833 gtk.main_quit() 1834 1835 def update_view(self): 1836 """ Update the UI to reflect the values in the Pattern Parameters. """ 1837 self.curve_type_combo.set_active(self.p.curve_type) 1838 self.curve_type_side_effects() 1839 1840 self.pattern_notebook.set_current_page(pattern_notation_page[self.p.pattern_notation]) 1841 1842 self.outer_teeth_adj.set_value(self.p.outer_teeth) 1843 self.inner_teeth_adj.set_value(self.p.inner_teeth) 1844 self.hole_percent_adj.set_value(self.p.hole_percent) 1845 self.pattern_rotation_adj.set_value(self.p.pattern_rotation) 1846 1847 self.kit_outer_teeth_combo.set_active(self.p.kit_fixed_gear_index) 1848 self.kit_inner_teeth_combo.set_active(self.p.kit_moving_gear_index) 1849 self.kit_hole_adj.set_value(self.p.hole_number) 1850 self.kit_inner_teeth_combo_side_effects() 1851 1852 self.petals_adj.set_value(self.p.petals) 1853 self.petal_skip_adj.set_value(self.p.petal_skip) 1854 self.doughnut_hole_adj.set_value(self.p.doughnut_hole) 1855 self.doughnut.set_hole_radius(self.p.doughnut_hole) 1856 self.doughnut_width_adj.set_value(self.p.doughnut_width) 1857 self.doughnut.set_width(self.p.doughnut_width) 1858 self.petals_changed_side_effects() 1859 1860 self.shape_combo.set_active(self.p.shape_index) 1861 self.shape_combo_side_effects() 1862 self.sides_adj.set_value(self.p.sides) 1863 self.morph_adj.set_value(self.p.morph) 1864 self.equal_w_h_checkbox.set_active(self.p.equal_w_h) 1865 self.shape_rotation_adj.set_value(self.p.shape_rotation) 1866 1867 self.margin_adj.set_value(self.p.margin_pixels) 1868 self.tool_combo.set_active(self.p.tool_index) 1869 self.long_gradient_checkbox.set_active(self.p.long_gradient) 1870 self.save_option_combo.set_active(self.p.save_option) 1871 1872 def reset_params(self, widget): 1873 self.engine.p = self.p = PatternParameters() 1874 self.update_view() 1875 1876 # Callbacks to handle changes in dialog parameters. 1877 1878 def curve_type_side_effects(self): 1879 if curve_types[self.p.curve_type].supports_shapes(): 1880 self.shape_combo.set_sensitive(True) 1881 1882 self.sides_myscale.set_sensitive(shapes[self.p.shape_index].has_sides()) 1883 self.morph_myscale.set_sensitive(shapes[self.p.shape_index].can_morph()) 1884 self.shape_rotation_myscale.set_sensitive(shapes[self.p.shape_index].can_rotate()) 1885 1886 self.hole_percent_myscale.set_sensitive(True) 1887 self.kit_hole_myscale.set_sensitive(True) 1888 1889 self.doughnut_hole_myscale.set_sensitive(True) 1890 self.doughnut_width_myscale.set_sensitive(True) 1891 else: 1892 # Lissajous curves do not have shapes, or holes for moving gear 1893 self.shape_combo.set_sensitive(False) 1894 1895 self.sides_myscale.set_sensitive(False) 1896 self.morph_myscale.set_sensitive(False) 1897 self.shape_rotation_myscale.set_sensitive(False) 1898 1899 self.hole_percent_myscale.set_sensitive(False) 1900 self.kit_hole_myscale.set_sensitive(False) 1901 1902 self.doughnut_hole_myscale.set_sensitive(False) 1903 self.doughnut_width_myscale.set_sensitive(False) 1904 1905 def curve_type_changed(self, val): 1906 self.p.curve_type = val.get_active() 1907 self.curve_type_side_effects() 1908 self.redraw() 1909 1910 def pattern_notation_tab_changed(self, notebook, page, page_num, user_param1=None): 1911 if self.enable_incremental_drawing: 1912 for notation in pattern_notation_page: 1913 if pattern_notation_page[notation] == page_num: 1914 self.p.pattern_notation = notation 1915 1916 self.redraw() 1917 1918 # Callbacks: pattern changes using the Toy Kit notation. 1919 1920 def kit_outer_teeth_combo_changed(self, val): 1921 self.p.kit_fixed_gear_index = val.get_active() 1922 self.redraw() 1923 1924 def kit_inner_teeth_combo_side_effects(self): 1925 # Change the max hole number according to the newly activated wheel. 1926 # We might also need to update the hole value, if it is larger than the new max. 1927 max_hole_number = self.p.kit_max_hole_number() 1928 if self.p.hole_number > max_hole_number: 1929 self.p.hole_number = max_hole_number 1930 self.kit_hole_adj.set_value(max_hole_number) 1931 self.kit_hole_adj.set_upper(max_hole_number) 1932 1933 def kit_inner_teeth_combo_changed(self, val): 1934 self.p.kit_moving_gear_index = val.get_active() 1935 self.kit_inner_teeth_combo_side_effects() 1936 self.redraw() 1937 1938 def kit_hole_changed(self, val): 1939 self.p.hole_number = val.value 1940 self.redraw() 1941 1942 # Callbacks: pattern changes using the Gears notation. 1943 1944 def outer_teeth_changed(self, val): 1945 self.p.outer_teeth = val.value 1946 self.redraw() 1947 1948 def inner_teeth_changed(self, val): 1949 self.p.inner_teeth = val.value 1950 self.redraw() 1951 1952 def hole_percent_changed(self, val): 1953 self.p.hole_percent = val.value 1954 self.redraw() 1955 1956 def pattern_rotation_changed(self, val): 1957 self.p.pattern_rotation = val.value 1958 self.redraw() 1959 1960 # Callbacks: pattern changes using the Visual notation. 1961 1962 def petals_changed_side_effects(self): 1963 max_petal_skip = int(self.p.petals/2) 1964 if self.p.petal_skip > max_petal_skip: 1965 self.p.petal_skip = max_petal_skip 1966 self.petal_skip_adj.set_value(max_petal_skip) 1967 self.petal_skip_adj.set_upper(max_petal_skip) 1968 1969 def petals_changed(self, val): 1970 self.p.petals = int(val.value) 1971 self.petals_changed_side_effects() 1972 self.redraw() 1973 1974 def petal_skip_changed(self, val): 1975 self.p.petal_skip = int(val.value) 1976 self.redraw() 1977 1978 def doughnut_hole_changed(self, val): 1979 self.p.doughnut_hole = val.value 1980 self.doughnut.set_hole_radius(val.value) 1981 self.redraw() 1982 1983 def doughnut_width_changed(self, val): 1984 self.p.doughnut_width = val.value 1985 self.doughnut.set_width(val.value) 1986 self.redraw() 1987 1988 def doughnut_changed(self, widget, hole, width): 1989 self.doughnut_hole_adj.set_value(hole) 1990 self.doughnut_width_adj.set_value(width) 1991 # We don't need to redraw, because the callbacks of the doughnut hole and 1992 # width spinners will be triggered by the above lines. 1993 1994 # Callbacks: Fixed gear 1995 1996 def shape_combo_side_effects(self): 1997 self.sides_myscale.set_sensitive(shapes[self.p.shape_index].has_sides()) 1998 self.morph_myscale.set_sensitive(shapes[self.p.shape_index].can_morph()) 1999 self.shape_rotation_myscale.set_sensitive(shapes[self.p.shape_index].can_rotate()) 2000 self.equal_w_h_checkbox.set_sensitive(shapes[self.p.shape_index].can_equal_w_h()) 2001 2002 def shape_combo_changed(self, val): 2003 self.p.shape_index = val.get_active() 2004 self.shape_combo_side_effects() 2005 self.redraw() 2006 2007 def sides_changed(self, val): 2008 self.p.sides = val.value 2009 self.redraw() 2010 2011 def morph_changed(self, val): 2012 self.p.morph = val.value 2013 self.redraw() 2014 2015 def equal_w_h_checkbox_changed(self, val): 2016 self.p.equal_w_h = val.get_active() 2017 self.redraw() 2018 2019 def shape_rotation_changed(self, val): 2020 self.p.shape_rotation = val.value 2021 self.redraw() 2022 2023 def margin_changed(self, val) : 2024 self.p.margin_pixels = val.value 2025 self.redraw() 2026 2027 # Style callbacks 2028 2029 def tool_changed_side_effects(self): 2030 self.long_gradient_checkbox.set_sensitive(tools[self.p.tool_index].can_color) 2031 2032 def tool_combo_changed(self, val): 2033 self.p.tool_index = val.get_active() 2034 self.tool_changed_side_effects() 2035 self.redraw() 2036 2037 def long_gradient_changed(self, val): 2038 self.p.long_gradient = val.get_active() 2039 self.redraw() 2040 2041 def save_option_changed(self, val): 2042 self.p.save_option = self.save_option_combo.get_active() 2043 2044 # Progress bar of plugin window. 2045 2046 def progress_start(self): 2047 self.progress_bar.set_text(_("Rendering Pattern")) 2048 self.progress_bar.set_fraction(0.0) 2049 pdb.gimp_displays_flush() 2050 2051 def progress_end(self): 2052 self.progress_bar.set_text("") 2053 self.progress_bar.set_fraction(0.0) 2054 2055 def progress_update(self): 2056 self.progress_bar.set_fraction(self.engine.fraction_done()) 2057 2058 def progress_unknown(self): 2059 self.progress_bar.set_text(_("Please wait : Rendering Pattern")) 2060 self.progress_bar.pulse() 2061 pdb.gimp_displays_flush() 2062 2063 # Incremental drawing. 2064 2065 def draw_next_chunk(self, tool=None): 2066 """ Incremental drawing """ 2067 2068 t = time.time() 2069 2070 chunk_size = self.engine.draw_next_chunk(self.drawing_layer, tool=tool) 2071 2072 draw_time = time.time() - t 2073 self.engine.report_time(draw_time) 2074 print("Chunk size " + str(chunk_size) + " time " + str(draw_time)) 2075 2076 if self.engine.has_more_strokes(): 2077 self.progress_update() 2078 else: 2079 self.progress_end() 2080 2081 pdb.gimp_displays_flush() 2082 2083 def start_new_incremental_drawing(self): 2084 """ 2085 Compute strokes for the current pattern, and store then in the IncrementalDraw object, 2086 so they can be drawn in pieces without blocking the user. 2087 Finally, draw the first chunk of strokes. 2088 """ 2089 2090 def incremental_drawing(): 2091 self.progress_start() 2092 yield True 2093 self.engine.reset_incremental() 2094 2095 self.img.undo_group_start() 2096 while self.engine.has_more_strokes(): 2097 yield True 2098 self.draw_next_chunk() 2099 self.img.undo_group_end() 2100 2101 self.idle_task = None 2102 yield False 2103 2104 # Start new idle task to perform incremental drawing in the background. 2105 self.clear_idle_task() 2106 task = incremental_drawing() 2107 self.idle_task = gobject.idle_add(task.next) 2108 2109 def clear(self): 2110 """ Clear current drawing. """ 2111 # pdb.gimp_edit_clear(self.spyro_layer) 2112 self.spyro_layer.fill(FILL_TRANSPARENT) 2113 2114 def redraw(self, data=None): 2115 if self.enable_incremental_drawing: 2116 self.clear() 2117 self.start_new_incremental_drawing() 2118 2119 2120# Bind escape to the new signal we created, named "myescape". 2121gobject.type_register(SpyroWindow) 2122gtk.binding_entry_add_signal(SpyroWindow, gtk.keysyms.Escape, 0, 'myescape', str, 'escape') 2123 2124 2125class SpyrogimpPlusPlugin(gimpplugin.plugin): 2126 2127 # Implementation of plugin. 2128 def plug_in_spyrogimp(self, run_mode, image, layer, 2129 curve_type=0, shape=0, sides=3, morph=0.0, 2130 fixed_teeth=96, moving_teeth=36, hole_percent=100.0, 2131 margin=0, equal_w_h=0, 2132 pattern_rotation=0.0, shape_rotation=0.0, 2133 tool=1, long_gradient=False): 2134 if run_mode == RUN_NONINTERACTIVE: 2135 pp = PatternParameters() 2136 pp.curve_type = curve_type 2137 pp.shape_index = shape 2138 pp.sides = sides 2139 pp.morph = morph 2140 pp.outer_teeth = fixed_teeth 2141 pp.inner_teeth = moving_teeth 2142 pp.hole_percent = hole_percent 2143 pp.margin_pixels = margin 2144 pp.equal_w_h = equal_w_h 2145 pp.pattern_rotation = pattern_rotation 2146 pp.shape_rotation = shape_rotation 2147 pp.tool_index = tool 2148 pp.long_gradient = long_gradient 2149 2150 engine = DrawingEngine(image, pp) 2151 engine.draw_full(layer) 2152 2153 elif run_mode == RUN_INTERACTIVE: 2154 window = SpyroWindow(image, layer) 2155 gtk.main() 2156 2157 elif run_mode == RUN_WITH_LAST_VALS: 2158 pp = unshelf_parameters() 2159 engine = DrawingEngine(image, pp) 2160 engine.draw_full(layer) 2161 2162 def query(self): 2163 plugin_name = "plug_in_spyrogimp" 2164 label = N_("Spyrogimp...") 2165 menu = "<Image>/Filters/Render/" 2166 2167 params = [ 2168 # (type, name, description 2169 (PDB_INT32, "run-mode", "The run mode { RUN-INTERACTIVE (0), RUN-NONINTERACTIVE (1) }"), 2170 (PDB_IMAGE, "image", "Input image"), 2171 (PDB_DRAWABLE, "drawable", "Input drawable"), 2172 (PDB_INT32, "curve_type", 2173 "The curve type { Spyrograph (0), Epitrochoid (1), Sine (2), Lissajous(3) }"), 2174 (PDB_INT32, "shape", "Shape of fixed gear"), 2175 (PDB_INT32, "sides", "Number of sides of fixed gear (3 or greater). Only used by some shapes."), 2176 (PDB_FLOAT, "morph", "Morph shape of fixed gear, between 0 and 1. Only used by some shapes."), 2177 (PDB_INT32, "fixed_teeth", "Number of teeth for fixed gear"), 2178 (PDB_INT32, "moving_teeth", "Number of teeth for moving gear"), 2179 (PDB_FLOAT, "hole_percent", "Location of hole in moving gear in percent, where 100 means that " 2180 "the hole is at the edge of the gear, and 0 means the hole is at the center"), 2181 (PDB_INT32, "margin", "Margin from selection, in pixels"), 2182 (PDB_INT32, "equal_w_h", "Make height and width equal (TRUE or FALSE)"), 2183 (PDB_FLOAT, "pattern_rotation", "Pattern rotation, in degrees"), 2184 (PDB_FLOAT, "shape_rotation", "Shape rotation of fixed gear, in degrees"), 2185 (PDB_INT32, "tool", "Tool to use for drawing the pattern."), 2186 (PDB_INT32, "long_gradient", 2187 "Whether to apply a long gradient to match the length of the pattern (TRUE or FALSE). " 2188 "Only applicable to some of the tools.") 2189 ] 2190 2191 gimp.domain_register("gimp20-python", gimp.locale_directory) 2192 2193 gimp.install_procedure( 2194 plugin_name, 2195 N_("Draw spyrographs using current tool settings and selection."), 2196 "Uses current tool settings to draw Spyrograph patterns. " 2197 "The size and location of the pattern is based on the current selection.", 2198 "Elad Shahar", 2199 "Elad Shahar", 2200 "2018", 2201 label, 2202 "*", 2203 PLUGIN, 2204 params, 2205 [] 2206 ) 2207 2208 gimp.menu_register(plugin_name, menu) 2209 2210 2211if __name__ == '__main__': 2212 SpyrogimpPlusPlugin().start() 2213