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